Repository: DanCardin/cappa (language: python)

Issue:
Confusing/unclear parsing output
Given the following in a file named `example.py`:

```python
import cappa

@cappa.command
class Example:
    x: int

cappa.parse(Example)
```

When I run the following:

```plain
python example.py a
```

I get the following output:

```plain
Traceback (most recent call last):
    ...
    value = arg.parse(value)
            ^^^^^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'a'
```

I would expect something a bit more helpful than a traceback that includes no information about the argument for which parsing failed. Obviously, since there's only 1 argument in this example, I know which one it's referring to, but for a more complex case, this is likely far from helpful in pinpointing the problem.

I would expect to see output more like the following, without any traceback:

```plain
ERROR: argument x: invalid literal for int() with base 10: 'a'
```

As an attempted workaround, I added a parser:

```python
def parse_x(s: str) -> int | NoReturn:
    try:
        return int(s)
    except Exception as e:
        raise cappa.Exit(str(e), code=1) from None


@cappa.command
class Example:
    x: Annotated[int, cappa.Arg(parse=parse_x)]
```

However, this time, running `python example.py a` produced absolutely no output at all, even though the exit code was properly set to `1`. Producing no output is even worse than producing a confusing traceback because the traceback at least makes it obvious that something went wrong.

Am I using the library incorrectly?


Discussion / hints:

Fix patch:
diff --git a/Makefile b/Makefile
index caacb07..e280f7e 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@
 PACKAGE_VERSION = $(shell python -c 'import importlib.metadata; print(importlib.metadata.version("responsaas"))')
 
 install:
-	poetry install -E rich
+	poetry install
 
 test:
 	coverage run -m pytest src tests
diff --git a/README.md b/README.md
index 312ae89..d6d2bbc 100644
--- a/README.md
+++ b/README.md
@@ -149,7 +149,7 @@ import cappa
 
 @dataclass
 class Example:
-    cmd: cappa.Subcmd[Print | Fail]
+    cmd: cappa.Subcommands[Print | Fail]
 
 
 def print_cmd(print: Print):
diff --git a/docs/source/api.md b/docs/source/api.md
index 44bf34a..d861a21 100644
--- a/docs/source/api.md
+++ b/docs/source/api.md
@@ -2,7 +2,7 @@
 
 ```{eval-rst}
 .. autoapimodule:: cappa
-   :members: parse, invoke, command, Command, Subcommand, Dep, Arg, ArgAction, Exit, Env
+   :members: parse, invoke, command, Command, Subcommand, Dep, Arg, ArgAction, Exit, Env, Completion, Output
 ```
 
 ```{eval-rst}
diff --git a/docs/source/backends.md b/docs/source/backends.md
new file mode 100644
index 0000000..28b704e
--- /dev/null
+++ b/docs/source/backends.md
@@ -0,0 +1,65 @@
+# Parser Backends
+
+Cappa is designed in two parts:
+
+- The "frontend", which is the vast majority of the public API. All the
+  available objects and functions, the annotation inference engine, the
+  invoke/dependency system, etc.
+
+- The parser "backend", which takes the object graph produced by your CLI
+  definition and parses the raw CLI (into a form the "frontend" can understand).
+
+  The backend also controls the help-text formatter
+
+There are currently two backend options:
+
+- A custom parser maintained inside cappa itself
+- An Argparse backend
+
+A large majority of tests are run with both parsers to ensure general
+compatibility. Any difference in backend parse **behavior** should be considered
+a bug. However, error **messaging** will not match exactly across backends.
+
+You select a backend by providing the `backend` argument to [parse](cappa.parse)
+or [invoke](cappa.invoke).
+
+```python
+import cappa
+
+cappa.parse(..., backend=cappa.argparse.backend)
+cappa.invoke(..., backend=cappa.argparse.backend)
+
+cappa.parse(..., backend=cappa.backend)
+cappa.invoke(..., backend=cappa.backend)
+```
+
+## Argparse backend
+
+Currently this is the default backend.
+
+Argparse is obviously fairly battle tested, and has some existing corpus of
+libraries designed to extend it. While we cant guarantee an arbitrary argparse
+extension will function correctly with cappa, but to the extent possible, that
+is a goal.
+
+This backend will be maintained for the foreseeable future.
+
+The main problem with the argparse backend is that cappa replaces much of the
+higher level features it provides at a higher level, outside the parser itself.
+
+A majority of the backend's source is working around specific argparse API
+decisions (a testament to the flexibility of its API, that it's even possible),
+and redefining argparse's objects with slight, necessary modifications.
+
+## Cappa backend
+
+Some "headline" features you get when using the cappa backend:
+
+- Automatic [completion](./completion.md) support
+- Automatic rich-powered, colored help-text formatting
+
+But generally, it's going to be easier to support arbitrary features with a
+custom parser than with an external backend.
+
+This backend is not currently the default due to its relative infancy. It will,
+however, **become** the default before 1.0 of the library.
diff --git a/docs/source/completion.md b/docs/source/completion.md
new file mode 100644
index 0000000..bbd7d8c
--- /dev/null
+++ b/docs/source/completion.md
@@ -0,0 +1,70 @@
+# Completion
+
+```{note}
+This feature is only supported when using the [cappa.backend](./backends.md).
+```
+
+Completion support is enabled by default. You can disable completion by sending
+`completion=False` into your [parse](cappa.parse) or [invoke](cappa.invoke)
+call.
+
+By default, this adds a `--completion [generate/complete]` argument to the
+resultant CLI. You can customize the name/visibility/help/etc of that argument
+by supplying an [Arg](cappa.Arg) to the same argument (`completion=Arg(...)`).
+
+## Enabling Completions
+
+```{note}
+Currently supported shells for completions include:
+
+- Zsh
+- Bash
+- Fish
+
+Submissions for additional shell support is very welcome.
+```
+
+`--completion generate` outputs `$SHELL`-specific source to power the
+completions for the CLI A user of your CLI needs to redirect to the appropriate
+default location for completions for that shell.
+
+Alternatively they can write it to an arbitrary location and source the
+resultant file in their shell-rc file, although this is not recommended!
+
+```bash
+mycli --completion generate > ~/.mycli.sh
+```
+
+## Completable Values
+
+Currently cappa supports completions for
+
+- `--option` long/short names
+- Subcommand names
+- Argument values
+
+  By default this does local file completion for most argument types.
+
+  If the argument defines logical "choices" (Enum, Union of Literals), those
+  choices will be completed instead.
+
+## Custom Completions
+
+Any [Arg](cappa.Arg) can supply a `completion=function` argument to produce
+arbitrary completions for that field. `function` (in this case) should be a
+function which accepts a string (the partial string to complete), and returns a
+list of [Completion](cappa.Completion)
+
+```python
+import cappa
+
+def names(partial: str) -> cappa.Completion:
+    names = ['Lucy', 'Bob', 'Ron']
+    return [
+        cappa.Completion(name, help='Available Names')
+        for name in names
+        if name.startswith(partial)
+    ]
+
+cappa.Arg(..., completion=names)
+```
diff --git a/docs/source/index.md b/docs/source/index.md
index c3484fc..9701d58 100644
--- a/docs/source/index.md
+++ b/docs/source/index.md
@@ -28,6 +28,14 @@ vs Click / Typer / Argparse <comparison>
 Parse Vs Invoke <parse_vs_invoke>
 Dataclasses/Pydantic/Attrs <class_compatibility>
 Rich Integration <rich>
+Completions <completion>
+Parser Backends (Cappa/Argparse) <backends>
+```
+
+```{toctree}
+:caption: Extra Details
+:hidden:
+
 Testing <testing>
 Exiting and Exit Codes <exiting>
 Manual Construction <manual_construction>
@@ -39,4 +47,5 @@ Internals <internals>
 :hidden:
 
 GitHub Repository <https://github.com/dancardin/cappa>
+PyPI <https://pypi.org/projects/cappa>
 ```
diff --git a/docs/source/invoke.md b/docs/source/invoke.md
index 27840f4..ba8eae0 100644
--- a/docs/source/invoke.md
+++ b/docs/source/invoke.md
@@ -120,7 +120,17 @@ There are two kinds of invoke dependencies, implicit and explicit.
 
 ### Implicit Dependencies
 
-These are objects of the types described in your CLI object heirarchy. That is,
+Implicit dependencies are known-unique object instances that the invoke system
+can produce for your function without an explicit `Dep` annotation.
+
+These objects include:
+
+- The root `Command` object
+- Any subcommand `Command` objects upstream of the selected subcommand
+- A [cappa.Output](cappa.Output), which can be used to produce (themed)
+  stdout/stderr output.
+
+For example:
 
 ```python
 @dataclass
diff --git a/docs/source/rich.md b/docs/source/rich.md
index 09ed6f8..3d2dc4c 100644
--- a/docs/source/rich.md
+++ b/docs/source/rich.md
@@ -1,23 +1,36 @@
 # Rich
 
-All features described in this page require the "rich" extra, installed like so:
-
-```bash
-pip install 'cappa[rich]'
+```{note}
+In order to get rich "support" (essentially colored help text) when using the
+argparse backend, you should separately depend upon the "rich-argparse" dependency.
 ```
 
 ## Color
 
-The installation of the 'rich' extra (which also makes use of rich-argparse
-currently), will automatically enable colored output, which enables colored
-output of (primarily) the CLI's help text.
-
-`invoke` or `parse` accept a `color: bool = True` argument which can be used to
-enable/disable the colored output directly. Alternatively, rich automatically
-respects the `NO_COLOR` environment variable for disabling colored output.
+Colored output, including help-text generation, is automatically enabled.
+[invoke](cappa.invoke) or [parse](cappa.parse) both accept a
+`color: bool = True` argument which can be used to enable/disable the colored
+output directly. Alternatively, rich automatically respects the `NO_COLOR`
+environment variable for disabling colored output.
 
 You can control individual styling of the help text by including rich directives
-inside the help text text. For example `Arg(help="[bold]Help[/bold]")`.
+inside the help text text. For example `Arg(help="I need [bold]Help[/bold]!")`.
+
+### Theming
+
+You can define your own rich `Theme` object and supply it into
+[invoke](cappa.invoke) or [parse](cappa.parse), which will used when rendering
+help-text output (and error messaging).
+
+Cappa's theme defines and uses the follwing style groups, which you would need
+to also supply:
+
+- `cappa.prog`
+- `cappa.group`
+- `cappa.arg`
+- `cappa.arg.name`
+- `cappa.subcommand`
+- `cappa.help`
 
 ## Prompt/Confirm
 
@@ -61,10 +74,9 @@ to simulate a user inputting values to stdin inside tests.
 
 ## Pretty Tracebacks
 
-Prettily rendering tracebacks can be a UX improvement for your CLI. Cappa does
-not currently define any specific integration with rich for tracebacks, given
-that you simply install rich's native traceback handler before calling
-parse/invoke.
+Prettily backending tracebacks can be a UX improvement for your CLI. Cappa does
+not define any specific integration with rich for tracebacks, given that you
+simply install rich's native traceback handler before calling parse/invoke.
 
 ```python
 import cappa
diff --git a/docs/source/subcommand.md b/docs/source/subcommand.md
index dc69242..29ea4f3 100644
--- a/docs/source/subcommand.md
+++ b/docs/source/subcommand.md
@@ -15,12 +15,12 @@ annotating the union with `cappa.Subcommand`. I.e.
 from __future__ import annotations
 from dataclasses import dataclass
 from typing import Annotated
-from cappa import Subcommand, Subcmd
+from cappa import Subcommand, Subcommands
 
 @dataclass
 class Command:
     # This
-    subcommand: Subcmd[SubcmdOne | SubcmdTwo]
+    subcommand: Subcommands[SubcmdOne | SubcmdTwo]
 
     # is shorthand for this
     subcommand2: Annotated[SubcmdOne | SubcmdTwo, Subcommand]
@@ -37,8 +37,9 @@ class SubcmdTwo:
     option: int
 ```
 
-- You can use the `Subcmd[...]` shorthand to avoid the use of `Annotated`, in
-  cases where you dont need to customize `Subcommand`'s constructor arguments.
+- You can use the `Subcommands[...]` shorthand to avoid the use of `Annotated`,
+  in cases where you dont need to customize `Subcommand`'s constructor
+  arguments.
 
 - Annotation with `Subcommand` will instantiate it no arguments, getting the
   default behavior
diff --git a/poetry.lock b/poetry.lock
index 9efe4ee..519fbfe 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -2,13 +2,13 @@
 
 [[package]]
 name = "annotated-types"
-version = "0.5.0"
+version = "0.6.0"
 description = "Reusable constraint types to use with typing.Annotated"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "annotated_types-0.5.0-py3-none-any.whl", hash = "sha256:58da39888f92c276ad970249761ebea80ba544b77acddaa1a4d6cf78287d45fd"},
-    {file = "annotated_types-0.5.0.tar.gz", hash = "sha256:47cdc3490d9ac1506ce92c7aaa76c579dc3509ff11e098fc867e5130ab7be802"},
+    {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
+    {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
 ]
 
 [package.dependencies]
@@ -105,63 +105,63 @@ files = [
 
 [[package]]
 name = "coverage"
-version = "7.3.1"
+version = "7.3.2"
 description = "Code coverage measurement for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "coverage-7.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cd0f7429ecfd1ff597389907045ff209c8fdb5b013d38cfa7c60728cb484b6e3"},
-    {file = "coverage-7.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:966f10df9b2b2115da87f50f6a248e313c72a668248be1b9060ce935c871f276"},
-    {file = "coverage-7.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0575c37e207bb9b98b6cf72fdaaa18ac909fb3d153083400c2d48e2e6d28bd8e"},
-    {file = "coverage-7.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:245c5a99254e83875c7fed8b8b2536f040997a9b76ac4c1da5bff398c06e860f"},
-    {file = "coverage-7.3.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c96dd7798d83b960afc6c1feb9e5af537fc4908852ef025600374ff1a017392"},
-    {file = "coverage-7.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:de30c1aa80f30af0f6b2058a91505ea6e36d6535d437520067f525f7df123887"},
-    {file = "coverage-7.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:50dd1e2dd13dbbd856ffef69196781edff26c800a74f070d3b3e3389cab2600d"},
-    {file = "coverage-7.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9c0c19f70d30219113b18fe07e372b244fb2a773d4afde29d5a2f7930765136"},
-    {file = "coverage-7.3.1-cp310-cp310-win32.whl", hash = "sha256:770f143980cc16eb601ccfd571846e89a5fe4c03b4193f2e485268f224ab602f"},
-    {file = "coverage-7.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:cdd088c00c39a27cfa5329349cc763a48761fdc785879220d54eb785c8a38520"},
-    {file = "coverage-7.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:74bb470399dc1989b535cb41f5ca7ab2af561e40def22d7e188e0a445e7639e3"},
-    {file = "coverage-7.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:025ded371f1ca280c035d91b43252adbb04d2aea4c7105252d3cbc227f03b375"},
-    {file = "coverage-7.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6191b3a6ad3e09b6cfd75b45c6aeeffe7e3b0ad46b268345d159b8df8d835f9"},
-    {file = "coverage-7.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7eb0b188f30e41ddd659a529e385470aa6782f3b412f860ce22b2491c89b8593"},
-    {file = "coverage-7.3.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75c8f0df9dfd8ff745bccff75867d63ef336e57cc22b2908ee725cc552689ec8"},
-    {file = "coverage-7.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7eb3cd48d54b9bd0e73026dedce44773214064be93611deab0b6a43158c3d5a0"},
-    {file = "coverage-7.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ac3c5b7e75acac31e490b7851595212ed951889918d398b7afa12736c85e13ce"},
-    {file = "coverage-7.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5b4ee7080878077af0afa7238df1b967f00dc10763f6e1b66f5cced4abebb0a3"},
-    {file = "coverage-7.3.1-cp311-cp311-win32.whl", hash = "sha256:229c0dd2ccf956bf5aeede7e3131ca48b65beacde2029f0361b54bf93d36f45a"},
-    {file = "coverage-7.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6f55d38818ca9596dc9019eae19a47410d5322408140d9a0076001a3dcb938c"},
-    {file = "coverage-7.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5289490dd1c3bb86de4730a92261ae66ea8d44b79ed3cc26464f4c2cde581fbc"},
-    {file = "coverage-7.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca833941ec701fda15414be400c3259479bfde7ae6d806b69e63b3dc423b1832"},
-    {file = "coverage-7.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd694e19c031733e446c8024dedd12a00cda87e1c10bd7b8539a87963685e969"},
-    {file = "coverage-7.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aab8e9464c00da5cb9c536150b7fbcd8850d376d1151741dd0d16dfe1ba4fd26"},
-    {file = "coverage-7.3.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87d38444efffd5b056fcc026c1e8d862191881143c3aa80bb11fcf9dca9ae204"},
-    {file = "coverage-7.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8a07b692129b8a14ad7a37941a3029c291254feb7a4237f245cfae2de78de037"},
-    {file = "coverage-7.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2829c65c8faaf55b868ed7af3c7477b76b1c6ebeee99a28f59a2cb5907a45760"},
-    {file = "coverage-7.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f111a7d85658ea52ffad7084088277135ec5f368457275fc57f11cebb15607f"},
-    {file = "coverage-7.3.1-cp312-cp312-win32.whl", hash = "sha256:c397c70cd20f6df7d2a52283857af622d5f23300c4ca8e5bd8c7a543825baa5a"},
-    {file = "coverage-7.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ae4c6da8b3d123500f9525b50bf0168023313963e0e2e814badf9000dd6ef92"},
-    {file = "coverage-7.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ca70466ca3a17460e8fc9cea7123c8cbef5ada4be3140a1ef8f7b63f2f37108f"},
-    {file = "coverage-7.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f2781fd3cabc28278dc982a352f50c81c09a1a500cc2086dc4249853ea96b981"},
-    {file = "coverage-7.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6407424621f40205bbe6325686417e5e552f6b2dba3535dd1f90afc88a61d465"},
-    {file = "coverage-7.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:04312b036580ec505f2b77cbbdfb15137d5efdfade09156961f5277149f5e344"},
-    {file = "coverage-7.3.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9ad38204887349853d7c313f53a7b1c210ce138c73859e925bc4e5d8fc18e7"},
-    {file = "coverage-7.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:53669b79f3d599da95a0afbef039ac0fadbb236532feb042c534fbb81b1a4e40"},
-    {file = "coverage-7.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:614f1f98b84eb256e4f35e726bfe5ca82349f8dfa576faabf8a49ca09e630086"},
-    {file = "coverage-7.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f1a317fdf5c122ad642db8a97964733ab7c3cf6009e1a8ae8821089993f175ff"},
-    {file = "coverage-7.3.1-cp38-cp38-win32.whl", hash = "sha256:defbbb51121189722420a208957e26e49809feafca6afeef325df66c39c4fdb3"},
-    {file = "coverage-7.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:f4f456590eefb6e1b3c9ea6328c1e9fa0f1006e7481179d749b3376fc793478e"},
-    {file = "coverage-7.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f12d8b11a54f32688b165fd1a788c408f927b0960984b899be7e4c190ae758f1"},
-    {file = "coverage-7.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f09195dda68d94a53123883de75bb97b0e35f5f6f9f3aa5bf6e496da718f0cb6"},
-    {file = "coverage-7.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6601a60318f9c3945be6ea0f2a80571f4299b6801716f8a6e4846892737ebe4"},
-    {file = "coverage-7.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07d156269718670d00a3b06db2288b48527fc5f36859425ff7cec07c6b367745"},
-    {file = "coverage-7.3.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:636a8ac0b044cfeccae76a36f3b18264edcc810a76a49884b96dd744613ec0b7"},
-    {file = "coverage-7.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5d991e13ad2ed3aced177f524e4d670f304c8233edad3210e02c465351f785a0"},
-    {file = "coverage-7.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:586649ada7cf139445da386ab6f8ef00e6172f11a939fc3b2b7e7c9082052fa0"},
-    {file = "coverage-7.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4aba512a15a3e1e4fdbfed2f5392ec221434a614cc68100ca99dcad7af29f3f8"},
-    {file = "coverage-7.3.1-cp39-cp39-win32.whl", hash = "sha256:6bc6f3f4692d806831c136c5acad5ccedd0262aa44c087c46b7101c77e139140"},
-    {file = "coverage-7.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:553d7094cb27db58ea91332e8b5681bac107e7242c23f7629ab1316ee73c4981"},
-    {file = "coverage-7.3.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:220eb51f5fb38dfdb7e5d54284ca4d0cd70ddac047d750111a68ab1798945194"},
-    {file = "coverage-7.3.1.tar.gz", hash = "sha256:6cb7fe1581deb67b782c153136541e20901aa312ceedaf1467dcb35255787952"},
+    {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"},
+    {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"},
+    {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"},
+    {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"},
+    {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"},
+    {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"},
+    {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"},
+    {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"},
+    {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"},
+    {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"},
+    {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"},
+    {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"},
+    {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"},
+    {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"},
+    {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"},
+    {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"},
+    {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"},
+    {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"},
+    {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"},
+    {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"},
+    {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"},
+    {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"},
+    {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"},
+    {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"},
+    {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"},
+    {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"},
+    {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"},
+    {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"},
+    {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"},
+    {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"},
+    {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"},
+    {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"},
+    {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"},
+    {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"},
+    {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"},
+    {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"},
+    {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"},
+    {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"},
+    {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"},
+    {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"},
+    {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"},
+    {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"},
+    {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"},
+    {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"},
+    {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"},
+    {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"},
+    {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"},
+    {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"},
+    {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"},
+    {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"},
+    {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"},
+    {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"},
 ]
 
 [package.extras]
@@ -207,7 +207,7 @@ files = [
 name = "markdown-it-py"
 version = "3.0.0"
 description = "Python port of markdown-it. Markdown parsing, done right!"
-optional = true
+optional = false
 python-versions = ">=3.8"
 files = [
     {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
@@ -231,7 +231,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
 name = "mdurl"
 version = "0.1.2"
 description = "Markdown URL utilities"
-optional = true
+optional = false
 python-versions = ">=3.7"
 files = [
     {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
@@ -297,13 +297,13 @@ files = [
 
 [[package]]
 name = "packaging"
-version = "23.1"
+version = "23.2"
 description = "Core utilities for Python packages"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"},
-    {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"},
+    {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
+    {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
 ]
 
 [[package]]
@@ -319,13 +319,13 @@ files = [
 
 [[package]]
 name = "platformdirs"
-version = "3.10.0"
+version = "3.11.0"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"},
-    {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"},
+    {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"},
+    {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"},
 ]
 
 [package.extras]
@@ -488,7 +488,7 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
 name = "pygments"
 version = "2.16.1"
 description = "Pygments is a syntax highlighting package written in Python."
-optional = true
+optional = false
 python-versions = ">=3.7"
 files = [
     {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"},
@@ -522,13 +522,13 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no
 
 [[package]]
 name = "rich"
-version = "13.5.3"
+version = "13.6.0"
 description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
-optional = true
+optional = false
 python-versions = ">=3.7.0"
 files = [
-    {file = "rich-13.5.3-py3-none-any.whl", hash = "sha256:9257b468badc3d347e146a4faa268ff229039d4c2d176ab0cffb4c4fbc73d5d9"},
-    {file = "rich-13.5.3.tar.gz", hash = "sha256:87b43e0543149efa1253f485cd845bb7ee54df16c9617b8a893650ab84b4acb6"},
+    {file = "rich-13.6.0-py3-none-any.whl", hash = "sha256:2b38e2fe9ca72c9a00170a1a2d20c63c790d0e10ef1fe35eba76e1e7b1d7d245"},
+    {file = "rich-13.6.0.tar.gz", hash = "sha256:5c14d22737e6d5084ef4771b62d5d4363165b403455a30a1c8ca39dc7b644bef"},
 ]
 
 [package.dependencies]
@@ -539,20 +539,6 @@ typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9
 [package.extras]
 jupyter = ["ipywidgets (>=7.5.1,<9)"]
 
-[[package]]
-name = "rich-argparse"
-version = "1.3.0"
-description = "Rich help formatters for argparse and optparse"
-optional = true
-python-versions = ">=3.7"
-files = [
-    {file = "rich_argparse-1.3.0-py3-none-any.whl", hash = "sha256:1a5eda1659c0a215862fe3630fcbe68d7792f18a8106baaf4e005b9896acc6f6"},
-    {file = "rich_argparse-1.3.0.tar.gz", hash = "sha256:974cc1ba0aaa0d6aabc09ab1b78f9ba928670e08590f9551121bcbc60c75b74a"},
-]
-
-[package.dependencies]
-rich = ">=11.0.0"
-
 [[package]]
 name = "ruff"
 version = "0.0.286"
@@ -616,10 +602,7 @@ files = [
 mypy-extensions = ">=0.3.0"
 typing-extensions = ">=3.7.4"
 
-[extras]
-rich = ["rich", "rich-argparse"]
-
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "1c7bd0d8ed63086758a5f9087b16df1311002139955cdf7d97e0a1231cc6e843"
+content-hash = "9ab4ffabde86d8de03ea458317cfec6f0d882df1920e81989bce356e803317a5"
diff --git a/pyproject.toml b/pyproject.toml
index 4fed16c..bca9d6b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,22 +1,18 @@
 [tool.poetry]
 name = "cappa"
-version = "0.6.0"
+version = "0.7.0"
 description = ""
 authors = ["DanCardin <ddcardin@gmail.com>"]
 readme = "README.md"
 packages = [{include = "cappa", from = "src"}]
 
-[tool.poetry.extras]
-rich = ["rich", "rich-argparse"]
-
 [tool.poetry.dependencies]
 python = "^3.8"
+
 typing-extensions = "^4.7.1"
 typing-inspect = "^0.9.0"
 docstring-parser = "^0.15"
-
-rich = {version = "*", optional = true}
-rich-argparse = {version = "*", optional = true}
+rich = "*"
 
 [tool.poetry.group.dev.dependencies]
 black = "^23.7.0"
@@ -51,6 +47,7 @@ ignore = [
 
 [tool.ruff.per-file-ignores]
 "tests/*" = ["T201"]
+"src/cappa/parser.py" = ["N818"]
 
 [tool.ruff.pyupgrade]
 keep-runtime-typing = true
@@ -84,3 +81,6 @@ doctest_optionflags = "NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
 addopts = "--doctest-modules -vv --ff --strict-markers"
 norecursedirs = ".* build dist *.egg bin"
 pytester_example_dir = "tests/examples"
+markers = [
+  "help",
+]
diff --git a/src/cappa/__init__.py b/src/cappa/__init__.py
index 7fb7ca9..53762e7 100644
--- a/src/cappa/__init__.py
+++ b/src/cappa/__init__.py
@@ -1,23 +1,33 @@
-from cappa.arg import Arg, ArgAction
 from cappa.base import command, invoke, parse
 from cappa.command import Command
+from cappa.completion.types import Completion
 from cappa.env import Env
 from cappa.invoke import Dep
-from cappa.output import Exit, print
-from cappa.subcommand import Subcmd, Subcommand, Subcommands
+from cappa.output import Exit, HelpExit, Output
+from cappa.subcommand import Subcommand, Subcommands
+
+# isort: split
+from cappa.arg import Arg, ArgAction
+
+# isort: split
+from cappa import argparse
+from cappa.parser import backend
 
 __all__ = [
     "Arg",
     "ArgAction",
     "Command",
+    "Completion",
     "Dep",
     "Env",
     "Exit",
-    "Subcmd",
+    "HelpExit",
+    "Output",
     "Subcommand",
     "Subcommands",
+    "argparse",
+    "backend",
     "command",
     "invoke",
     "parse",
-    "print",
 ]
diff --git a/src/cappa/annotation.py b/src/cappa/annotation.py
index fe31bfe..4cf0eaa 100644
--- a/src/cappa/annotation.py
+++ b/src/cappa/annotation.py
@@ -6,7 +6,7 @@
 
 from typing_inspect import is_literal_type
 
-from cappa.typing import T, is_none_type, is_subclass, is_union_type, render_type
+from cappa.typing import T, backend_type, is_none_type, is_subclass, is_union_type
 
 __all__ = [
     "parse_value",
@@ -141,7 +141,7 @@ def union_mapper(value):
                 pass
 
         raise ValueError(
-            f"Could not parse '{value}' given options: {', '.join(render_type(t) for t in type_args)}"
+            f"Could not parse '{value}' given options: {', '.join(backend_type(t) for t in type_args)}"
         )
 
     return union_mapper
diff --git a/src/cappa/arg.py b/src/cappa/arg.py
index a13f176..06954c9 100644
--- a/src/cappa/arg.py
+++ b/src/cappa/arg.py
@@ -10,6 +10,8 @@
 
 from cappa.annotation import detect_choices, parse_value
 from cappa.class_inspect import Field, extract_dataclass_metadata
+from cappa.completion.completers import complete_choices
+from cappa.completion.types import Completion
 from cappa.subcommand import Subcommand
 from cappa.typing import (
     MISSING,
@@ -29,6 +31,14 @@ class ArgAction(enum.Enum):
     append = "append"
     count = "count"
 
+    help = "help"
+    version = "version"
+    completion = "completion"
+
+    @classmethod
+    def value_actions(cls) -> typing.Set[ArgAction]:
+        return {cls.help, cls.version, cls.completion}
+
 
 @dataclasses.dataclass
 class Arg(typing.Generic[T]):
@@ -54,12 +64,20 @@ class Arg(typing.Generic[T]):
         parse: An optional function which accepts the raw string argument as input and
             returns a parsed value type's instance. This should only be required for
             complex types that the type system's built-in parsing cannot handle.
+
+        group: Optional group names for the argument. This affects how they're displayed
+            in the backended help text.
+        hidden: Whether the argument should be hidden in help text. Defaults to False.
+
         action: Generally automatically inferred from the data type. This allows to
             override the default.
         num_args: Generally automatically inferred from the data type. This allows to
             override the default.
         choices: Generally automatically inferred from the data type. This allows to
             override the default.
+        completion: Used to provide custom completions. If specified, should be a function
+            which acccepts a partial string value and returns a list of
+            [cappa.Completion](cappa.Completion) objects.
 
         required: Defaults to automatically inferring requiredness, based on whether the
             class's value has a default. By setting this, you can force a particular value.
@@ -73,9 +91,13 @@ class Arg(typing.Generic[T]):
     help: str | None = None
     parse: Callable[[typing.Any], T] | None = None
 
+    group: str | tuple[int, str] | MISSING = missing
+    hidden: bool = False
+
     action: ArgAction | None = None
     num_args: int | None = None
     choices: list[str] | None = None
+    completion: Callable[..., list[Completion]] | None = None
 
     required: bool | None = None
 
@@ -107,23 +129,29 @@ def normalize(
         self,
         annotation=NoneType,
         fallback_help: str | None = None,
+        action: ArgAction | None = None,
+        name: str | None = None,
     ) -> Arg:
         origin = typing.get_origin(annotation) or annotation
         type_args = typing.get_args(annotation)
         required = infer_required(self, annotation, self.default)
 
-        name = typing.cast(str, self.name)
+        name = typing.cast(str, name or self.name)
         short = infer_short(self, name)
         long = infer_long(self, origin, name)
         choices = infer_choices(self, origin, type_args)
-        action = infer_action(self, origin, type_args, long, self.default)
+        action = action or infer_action(self, origin, type_args, long, self.default)
         num_args = infer_num_args(self, origin, type_args, long)
 
         parse = infer_parse(self, annotation)
         help = infer_help(self, choices, fallback_help)
+        completion = infer_completion(self, choices)
+
+        group = infer_group(self, short, long)
 
         return dataclasses.replace(
             self,
+            name=name,
             required=required,
             short=short,
             long=long,
@@ -132,8 +160,21 @@ def normalize(
             num_args=num_args,
             parse=parse,
             help=help,
+            completion=completion,
+            group=group,
         )
 
+    def names(self) -> list[str]:
+        short_names = typing.cast(list, self.short or [])
+        long_names = typing.cast(list, self.long or [])
+        return short_names + long_names
+
+    def names_str(self, delimiter: str = ", ") -> str:
+        if self.long or self.short:
+            return delimiter.join(self.names())
+
+        return typing.cast(str, self.name)
+
 
 def infer_name(arg: Arg, field: Field) -> str:
     if not isinstance(arg.name, MISSING):
@@ -160,6 +201,9 @@ def infer_required(arg: Arg, annotation: type, default: typing.Any | MISSING):
         return arg.required
 
     if default is missing:
+        if is_subclass(annotation, bool):
+            return False
+
         return not is_optional_type(annotation)
 
     return False
@@ -178,7 +222,7 @@ def infer_short(arg: Arg, name: str) -> list[str] | typing.Literal[False]:
     else:
         short = arg.short
 
-    return [item if item.startswith("-") else f"-{item}" for item in short]
+    return [item[:2] if item.startswith("-") else f"-{item[0]}" for item in short]
 
 
 def infer_long(arg: Arg, origin: type, name: str) -> list[str] | typing.Literal[False]:
@@ -267,6 +311,9 @@ def infer_num_args(
         if is_positional:
             return -1
 
+    if is_subclass(origin, bool):
+        return 1
+
     return None
 
 
@@ -293,3 +340,42 @@ def infer_help(
         return f"{help} {choices_str}"
 
     return choices_str
+
+
+def infer_completion(
+    arg: Arg, choices: list[str] | None
+) -> Callable[[str], list[Completion]] | None:
+    if arg.completion:
+        return arg.completion
+
+    if choices:
+        return complete_choices(choices, help=arg.help)
+
+    return None
+
+
+def infer_group(
+    arg: Arg, short: list[str] | bool, long: list[str] | bool
+) -> str | tuple[int, str]:
+    group = arg.group
+    group_name = None
+    if isinstance(group, str):
+        group_name = group
+        group = missing
+
+    if group is missing:
+        if short or long:
+            return (0, group_name or "Options")
+
+        return (1, group_name or "Arguments")
+
+    return typing.cast(typing.Tuple[int, str], group)
+
+
+no_extra_arg_actions = {
+    ArgAction.store_true,
+    ArgAction.store_false,
+    ArgAction.count,
+    ArgAction.version,
+    ArgAction.help,
+}
diff --git a/src/cappa/argparse.py b/src/cappa/argparse.py
index 184bbec..a6e4fdc 100644
--- a/src/cappa/argparse.py
+++ b/src/cappa/argparse.py
@@ -7,8 +7,9 @@
 from typing_extensions import assert_never
 
 from cappa.arg import Arg, ArgAction
-from cappa.command import Command, Subcommands
-from cappa.output import Exit
+from cappa.command import Command, Subcommand
+from cappa.help import create_help_arg, create_version_arg, generate_arg_groups
+from cappa.output import Exit, HelpExit
 from cappa.typing import assert_not_missing, assert_type, missing
 
 if sys.version_info < (3, 9):  # pragma: no cover
@@ -38,6 +39,10 @@ def __init__(self, metavar=None, **kwargs):
         self.metavar = metavar
         super().__init__(**kwargs)
 
+    def __call__(self, parser, namespace, values, option_string=None):
+        parser.print_help()
+        raise HelpExit("", code=0)
+
 
 class _VersionAction(argparse._VersionAction):
     def __init__(self, metavar=None, **kwargs):
@@ -64,8 +69,7 @@ def __init__(self, metavar=None, **kwargs):
 
 
 class ArgumentParser(argparse.ArgumentParser):
-    def __init__(self, *args, exit_with, **kwargs):
-        self.exit_with = exit_with
+    def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
         self.register("action", "store_true", _StoreTrueAction)
@@ -75,7 +79,7 @@ def __init__(self, *args, exit_with, **kwargs):
         self.register("action", "count", _CountAction)
 
     def exit(self, status=0, message=None):
-        raise self.exit_with(message, code=status)
+        raise Exit(message, code=status)
 
 
 class BooleanOptionalAction(argparse.Action):
@@ -115,38 +119,36 @@ def __setattr__(self, name, value):
             self.__dict__[name] = value
 
 
-def render(
+def backend(
     command: Command[T],
     argv: list[str],
-    exit_with=None,
     color: bool = True,
     version: str | Arg | None = None,
     help: bool | Arg = True,
-) -> tuple[Command[T], dict[str, typing.Any]]:
-    if exit_with is None:
-        exit_with = Exit
+    completion: bool | Arg = True,
+) -> tuple[typing.Any, Command[T], dict[str, typing.Any]]:
+    version = create_version_arg(version)
+    command.add_meta_actions(help=create_help_arg(help), version=version)
 
-    parser = create_parser(command, exit_with, color=color)
-    add_help_group(parser, version=version, help=help)
+    parser = create_parser(command, color=color)
+    if version:
+        parser.version = version.name  # type: ignore
 
     ns = Nestedspace()
 
     try:
         result_namespace = parser.parse_args(argv[1:], ns)
     except argparse.ArgumentError as e:
-        raise exit_with(str(e), code=127)
-    except Exit as e:
-        raise exit_with(e.message, code=e.code)
+        raise Exit(str(e), code=2)
 
     result = to_dict(result_namespace)
     command = result.pop("__command__")
 
-    return command, result
+    return parser, command, result
 
 
 def create_parser(
     command: Command,
-    exit_with: typing.Callable,
     color: bool = True,
 ) -> argparse.ArgumentParser:
     kwargs: dict[str, typing.Any] = {}
@@ -156,7 +158,6 @@ def create_parser(
     parser = ArgumentParser(
         prog=command.name,
         description=join_help(command.help, command.description),
-        exit_with=exit_with,
         allow_abbrev=False,
         add_help=False,
         formatter_class=choose_help_formatter(color=color),
@@ -169,43 +170,6 @@ def create_parser(
     return parser
 
 
-def add_help_group(
-    parser: argparse.ArgumentParser,
-    version: str | Arg | None = None,
-    help: bool | Arg = True,
-):
-    if not version and not help:
-        return
-
-    help_group = parser.add_argument_group("help")
-    if version:
-        if isinstance(version, str):
-            arg: Arg = Arg(
-                version,
-                short=["-v"],
-                long=["--version"],
-                help="Show the version and exit.",
-            )
-        else:
-            arg = version.normalize()
-
-        add_argument(help_group, arg, version=arg.name, action=_VersionAction)
-
-    if help:
-        if isinstance(help, bool):
-            arg = Arg(
-                name="help",
-                short=["-h"],
-                long=["--help"],
-                help="Show this message and exit.",
-            )
-        else:
-            arg = help.normalize()
-            arg.name = "help"
-
-        add_argument(help_group, arg, action=_HelpAction)
-
-
 def choose_help_formatter(color: bool = True):
     help_formatter: type[
         argparse.HelpFormatter
@@ -222,16 +186,18 @@ def choose_help_formatter(color: bool = True):
     return help_formatter
 
 
-def add_arguments(
-    parser: argparse.ArgumentParser, command: Command, dest_prefix="", exit_with=Exit
-):
-    for arg in command.arguments:
-        if isinstance(arg, Arg):
-            add_argument(parser, arg, dest_prefix=dest_prefix)
-        elif isinstance(arg, Subcommands):
-            add_subcommands(parser, arg, dest_prefix=dest_prefix, exit_with=exit_with)
-        else:
-            assert_never(arg)
+def add_arguments(parser: argparse.ArgumentParser, command: Command, dest_prefix=""):
+    arg_groups = generate_arg_groups(command, include_hidden=True)
+    for group_name, args in arg_groups:
+        group = parser.add_argument_group(title=group_name)
+
+        for arg in args:
+            if isinstance(arg, Arg):
+                add_argument(group, arg, dest_prefix=dest_prefix)
+            elif isinstance(arg, Subcommand):
+                add_subcommands(parser, group_name, arg, dest_prefix=dest_prefix)
+            else:
+                assert_never(arg)
 
 
 def add_argument(
@@ -253,18 +219,15 @@ def add_argument(
 
     is_positional = not names
 
-    num_args = render_num_args(arg.num_args)
+    num_args = backend_num_args(arg.num_args)
 
     kwargs: dict[str, typing.Any] = {
         "dest": dest_prefix + name,
         "help": arg.help,
         "metavar": name,
+        "action": get_action(arg),
     }
 
-    action = get_action(arg)
-    if action:
-        kwargs["action"] = action
-
     if not is_positional and arg.required:
         kwargs["required"] = arg.required
 
@@ -274,8 +237,8 @@ def add_argument(
         else:
             kwargs["default"] = arg.default
 
-    if num_args is not None and (
-        arg.action is None or arg.action is not arg.action.store_true
+    if num_args and (
+        arg.action and arg.action not in {arg.action.store_true, arg.action.store_false}
     ):
         kwargs["nargs"] = num_args
     elif is_positional and not arg.required:
@@ -291,14 +254,14 @@ def add_argument(
 
 def add_subcommands(
     parser: argparse.ArgumentParser,
-    subcommands: Subcommands,
+    group: str,
+    subcommands: Subcommand,
     dest_prefix="",
-    exit_with=Exit,
 ):
     subcommand_dest = subcommands.name
     subparsers = parser.add_subparsers(
-        title=subcommand_dest,
-        required=subcommands.required,
+        title=group,
+        required=assert_type(subcommands.required, bool),
         description=subcommands.help,
         parser_class=ArgumentParser,
     )
@@ -310,7 +273,7 @@ def add_subcommands(
             help=subcommand.help,
             description=subcommand.description,
             formatter_class=parser.formatter_class,
-            exit_with=exit_with,  # type: ignore
+            add_help=False,
         )
         subparser.set_defaults(
             __command__=subcommand, **{nested_dest_prefix + "__name__": name}
@@ -323,7 +286,7 @@ def add_subcommands(
         )
 
 
-def render_num_args(num_args: int | None) -> int | str | None:
+def backend_num_args(num_args: int | None) -> int | str | None:
     if num_args is None:
         return None
 
@@ -347,14 +310,12 @@ def join_help(*segments):
     return " ".join([s for s in segments if s])
 
 
-def get_action(arg: Arg) -> type[argparse.Action] | str | None:
-    if not arg.action:
-        return None
-
-    if arg.action in {ArgAction.store_true, ArgAction.store_false}:
+def get_action(arg: Arg) -> type[argparse.Action] | str:
+    action = assert_type(arg.action, ArgAction)
+    if action in {ArgAction.store_true, ArgAction.store_false}:
         long = assert_type(arg.long, list)
         has_no_option = any("--no-" in i for i in long)
         if has_no_option:
             return BooleanOptionalAction
 
-    return arg.action.value
+    return action.value
diff --git a/src/cappa/base.py b/src/cappa/base.py
index dfcd96e..7502850 100644
--- a/src/cappa/base.py
+++ b/src/cappa/base.py
@@ -4,12 +4,16 @@
 import sys
 import typing
 
+from rich.theme import Theme
 from typing_extensions import dataclass_transform
 
-from cappa.arg import Arg
 from cappa.class_inspect import detect
 from cappa.command import Command
 from cappa.invoke import invoke_callable
+from cappa.output import Output
+
+if typing.TYPE_CHECKING:
+    from cappa.arg import Arg
 
 T = typing.TypeVar("T")
 
@@ -18,11 +22,12 @@ def parse(
     obj: type[T],
     *,
     argv: list[str] | None = None,
-    render: typing.Callable | None = None,
-    exit_with=None,
+    backend: typing.Callable | None = None,
     color: bool = True,
     version: str | Arg | None = None,
     help: bool | Arg = True,
+    completion: bool | Arg = True,
+    theme: Theme | None = None,
 ) -> T:
     """Parse the command, returning an instance of `obj`.
 
@@ -33,30 +38,34 @@ def parse(
         obj: A class which can represent a CLI command chain.
         argv: Defaults to the process argv. This command is generally only
             necessary when testing.
-        render: A function used to perform the underlying parsing and return a raw
+        backend: A function used to perform the underlying parsing and return a raw
             parsed state. This defaults to constructing built-in function using argparse.
-        exit_with: Used when parsing fails, to raise/indicate failure. By default, exits
-            with SystemExit to kill the process.
         color: Whether to output in color, if the `color` extra is installed.
         version: If a string is supplied, adds a -v/--version flag which returns the
             given version string. If an `Arg` is supplied, uses the `name`/`short`/`long`/`help`
             fields to add a corresponding version argument.
         help: If `True` (default to True), adds a -h/--help flag. If an `Arg` is supplied,
             uses the `short`/`long`/`help` fields to add a corresponding help argument.
+        completion: Enables completion when using the cappa `backend` option. If `True`
+        (default to True), adds a --completion flag. An `Arg` can be supplied to customize
+        the argument's behavior.
+        theme: Optional rich theme to customized output formatting.
     """
     if argv is None:  # pragma: no cover
         argv = sys.argv
 
     command = Command.get(obj)
 
+    output = Output.from_theme(theme)
     _, _, instance = Command.parse_command(
         command,
         argv=argv,
-        render=render,
-        exit_with=exit_with,
+        backend=backend,
         color=color,
         version=version,
         help=help,
+        output=output,
+        completion=completion,
     )
 
     return instance
@@ -67,11 +76,12 @@ def invoke(
     *,
     deps: typing.Sequence[typing.Callable] | None = None,
     argv: list[str] | None = None,
-    render: typing.Callable | None = None,
-    exit_with=None,
+    backend: typing.Callable | None = None,
     color: bool = True,
     version: str | Arg | None = None,
     help: bool | Arg = True,
+    completion: bool | Arg = True,
+    theme: Theme | None = None,
 ):
     """Parse the command, and invoke the selected command or subcommand.
 
@@ -84,37 +94,42 @@ def invoke(
             deps are evaulated in order and unconditionally.
         argv: Defaults to the process argv. This command is generally only
             necessary when testing.
-        render: A function used to perform the underlying parsing and return a raw
+        backend: A function used to perform the underlying parsing and return a raw
             parsed state. This defaults to constructing built-in function using argparse.
-        exit_with: Used when parsing fails, to raise/indicate failure. By default, exits
-            with SystemExit to kill the process.
         color: Whether to output in color, if the `color` extra is installed.
         version: If a string is supplied, adds a -v/--version flag which returns the
             given version string. If an `Arg` is supplied, uses the `name`/`short`/`long`
             fields to add a corresponding version argument.
         help: If `True` (default to True), adds a -h/--help flag. If an `Arg` is supplied,
             uses the `short`/`long`/`help` fields to add a corresponding help argument.
+        completion: Enables completion when using the cappa `backend` option. If `True`
+        (default to True), adds a --completion flag. An `Arg` can be supplied to customize
+        the argument's behavior.
+        theme: Optional rich theme to customized output formatting.
     """
     if argv is None:  # pragma: no cover
         argv = sys.argv
 
     command: Command = Command.get(obj)
 
+    output = Output.from_theme(theme)
     command, parsed_command, instance = Command.parse_command(
         command,
         argv=argv,
-        render=render,
-        exit_with=exit_with,
+        backend=backend,
         color=color,
         version=version,
         help=help,
+        output=output,
+        completion=completion,
     )
 
-    return invoke_callable(command, parsed_command, instance, deps=deps)
+    return invoke_callable(command, parsed_command, instance, output=output, deps=deps)
 
 
 @dataclass_transform()
 def command(
+    _cls=None,
     *,
     name: str | None = None,
     help: str | None = None,
@@ -151,4 +166,6 @@ def wrapper(_decorated_cls):
         _decorated_cls.__cappa__ = instance
         return _decorated_cls
 
+    if _cls is not None:
+        return wrapper(_cls)
     return wrapper
diff --git a/src/cappa/class_inspect.py b/src/cappa/class_inspect.py
index e21d7cf..d0b57e6 100644
--- a/src/cappa/class_inspect.py
+++ b/src/cappa/class_inspect.py
@@ -86,7 +86,9 @@ def from_pydantic(cls, typ: type) -> list[Self]:
             field = cls(
                 name=name,
                 annotation=f.annotation,
-                default=f.default or missing,
+                default=f.default
+                if f.default.__repr__() != "PydanticUndefined"
+                else missing,
                 default_factory=f.default_factory or missing,
             )
             fields.append(field)
diff --git a/src/cappa/command.py b/src/cappa/command.py
index 180f5dc..56d17e5 100644
--- a/src/cappa/command.py
+++ b/src/cappa/command.py
@@ -9,19 +9,17 @@
 from typing_extensions import Self, get_type_hints
 
 from cappa import class_inspect
-from cappa.arg import Arg
+from cappa.arg import Arg, ArgAction
 from cappa.env import Env
-from cappa.output import Exit
-from cappa.rich import prompt_types
-from cappa.subcommand import Subcommand, Subcommands
-from cappa.typing import assert_not_missing
+from cappa.output import Exit, Output, prompt_types
+from cappa.subcommand import Subcommand
 
 T = typing.TypeVar("T")
 
 
 class CommandArgs(typing.TypedDict, total=False):
     cmd_cls: type
-    arguments: list[Arg | Subcommands]
+    arguments: list[Arg | Subcommand]
     name: str | None
     help: str | None
     description: str | None
@@ -51,7 +49,7 @@ class Command(typing.Generic[T]):
     """
 
     cmd_cls: typing.Type[T]
-    arguments: list[Arg | Subcommands] = dataclasses.field(default_factory=list)
+    arguments: list[Arg | Subcommand] = dataclasses.field(default_factory=list)
     name: str | None = None
     help: str | None = None
     description: str | None = None
@@ -91,11 +89,10 @@ def collect(cls, command: Command):
                 kwargs["description"] = parsed_help.long_description
 
         if command.arguments:
-            arguments: list[Arg | Subcommands] = [
+            arguments: list[Arg | Subcommand] = [
                 a.normalize() for a in command.arguments
             ]
         else:
-            command_cls = type(command)
             fields = class_inspect.fields(command.cmd_cls)
             type_hints = get_type_hints(command.cmd_cls, include_extras=True)
 
@@ -106,24 +103,7 @@ def collect(cls, command: Command):
 
                 maybe_subcommand = Subcommand.collect(field, type_hint)
                 if maybe_subcommand:
-                    subcommand = maybe_subcommand
-
-                    types: typing.Iterable[type] = assert_not_missing(subcommand.types)
-
-                    options = {}
-                    for type_ in types:
-                        type_command = command_cls.get(type_)
-                        type_name = type_command.real_name()
-                        options[type_name] = cls.collect(type_command)
-
-                    subcommands: Subcommands = Subcommands(
-                        name=assert_not_missing(subcommand.name),
-                        required=subcommand.required or False,
-                        help=subcommand.help,
-                        options=options,
-                    )
-                    arguments.append(subcommands)
-
+                    arguments.append(maybe_subcommand)
                 else:
                     arg_help = arg_help_map.get(field.name)
                     arg_def: Arg = Arg.collect(field, type_hint, fallback_help=arg_help)
@@ -139,57 +119,104 @@ def parse_command(
         command: Command[T],
         *,
         argv: list[str],
-        render: typing.Callable | None = None,
-        exit_with=None,
+        output: Output,
+        backend: typing.Callable | None = None,
         color: bool = True,
         version: str | Arg | None = None,
         help: bool | Arg = True,
+        completion: bool | Arg = True,
     ) -> tuple[Command, Command[T], T]:
         command = cls.collect(command)
 
-        if render is None:  # pragma: no cover
+        if backend is None:  # pragma: no cover
             from cappa import argparse
 
-            render = argparse.render
+            backend = argparse.backend
 
         try:
-            parsed_command, parsed_args = render(
+            _, parsed_command, parsed_args = backend(
                 command,
                 argv,
-                exit_with=exit_with,
                 color=color,
                 version=version,
                 help=help,
+                completion=completion,
             )
+            result = command.map_result(command, parsed_args)
         except Exit as e:
-            e.print()
+            output.exit(e)
             raise
 
-        result = command.map_result(command, parsed_args)
         return command, parsed_command, result
 
     def map_result(self, command: Command[T], parsed_args) -> T:
         kwargs = {}
-        for arg in self.arguments:
+        for arg in self.value_arguments():
+            is_subcommand = isinstance(arg, Subcommand)
             if arg.name not in parsed_args:
-                continue
+                # if arg.required:
+                #     raise Exit(
+                #         f"The following arguments are required: {arg.names_str()}",
+                #         code=1,
+                #     )
+
+                if is_subcommand:
+                    continue
+
+                value = arg.default
+            else:
+                value = parsed_args[arg.name]
 
-            value = parsed_args[arg.name]
             if isinstance(value, Env):
                 value = value.evaluate()
             if isinstance(value, prompt_types):
                 value = value()
 
-            if isinstance(arg, Subcommands):
+            if isinstance(arg, Subcommand):
                 value = arg.map_result(value)
             else:
                 assert arg.parse
-                value = arg.parse(value)
+
+                try:
+                    value = arg.parse(value)
+                except Exception as e:
+                    exception_reason = str(e)
+                    raise Exit(
+                        f"Invalid value for '{arg.names_str()}' with value '{value}': {exception_reason}",
+                        code=2,
+                    )
 
             kwargs[arg.name] = value
 
         return command.cmd_cls(**kwargs)
 
+    def value_arguments(self):
+        for arg in self.arguments:
+            if isinstance(arg, Arg):
+                if arg.action in ArgAction.value_actions():
+                    continue
+
+            yield arg
+
+    def add_meta_actions(
+        self,
+        help: Arg | None = None,
+        version: Arg | None = None,
+        completion: Arg | None = None,
+    ):
+        if help:
+            for arg in self.arguments:
+                if isinstance(arg, Subcommand):
+                    for option in arg.options.values():
+                        option.add_meta_actions(help)
+
+            self.arguments.append(help)
+        if version:
+            self.arguments.append(version)
+        if completion:
+            self.arguments.append(completion)
+        return self
+
 
 H = typing.TypeVar("H", covariant=True)
 
diff --git a/src/cappa/completion/__init__.py b/src/cappa/completion/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/cappa/completion/base.py b/src/cappa/completion/base.py
new file mode 100644
index 0000000..9338733
--- /dev/null
+++ b/src/cappa/completion/base.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import os
+import typing
+from pathlib import Path
+
+from cappa.arg import Arg
+from cappa.command import Command
+from cappa.completion.shells import available_shells
+from cappa.output import Exit
+from cappa.parser import Completion, FileCompletion, backend
+
+
+def execute(
+    command: Command,
+    prog: str,
+    action: str,
+    help: Arg | None,
+    version: Arg | None,
+    completion: Arg,
+):
+    shell_name = Path(os.environ.get("SHELL", "bash")).name
+    shell = available_shells.get(shell_name)
+
+    if shell is None:
+        raise Exit("Unknown shell", code=1)
+
+    if action == "generate":
+        raise Exit(shell.backend_template(prog, completion), code=0)
+
+    command_args = parse_incomplete_command()
+
+    backend(
+        command,
+        [prog, *command_args],
+        version=version,
+        help=help,
+        provide_completions=True,
+    )
+
+
+def parse_incomplete_command() -> list[str]:
+    raw_completion_line = os.environ.get("COMPLETION_LINE", "").replace("\n", " ")
+    raw_completion_location = os.environ.get("COMPLETION_LOCATION")
+    if raw_completion_line is None or raw_completion_location is None:
+        raise Exit(code=0)
+
+    completion_line = list(split_incomplete_command(raw_completion_line))
+    completion_index = int(raw_completion_location)
+
+    return completion_line[1:completion_index]
+
+
+def split_incomplete_command(string: str) -> typing.Iterable[str]:
+    import shlex
+
+    lex = shlex.shlex(string, posix=True)
+    lex.whitespace_split = True
+    lex.commenters = ""
+
+    try:
+        yield from lex
+    except ValueError:
+        yield lex.token
+
+
+def format_completions(*completions: Completion | FileCompletion) -> str | None:
+    if not completions:
+        return None
+
+    if isinstance(completions[0], FileCompletion):
+        return "file"
+
+    result = []
+    for item in completions:
+        item = typing.cast(Completion, item)
+        result.append(f"{item.value}:{item.help if item.help else ''}")
+    return "\n".join(result)
diff --git a/src/cappa/completion/completers.py b/src/cappa/completion/completers.py
new file mode 100644
index 0000000..1b3413f
--- /dev/null
+++ b/src/cappa/completion/completers.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+from cappa.completion.types import Completion
+
+
+def complete_choices(choices: list[str], help: str | None = None):
+    def completion(partial: str = ""):
+        return [Completion(c, help=help) for c in choices if partial in c]
+
+    return completion
diff --git a/src/cappa/completion/shells.py b/src/cappa/completion/shells.py
new file mode 100644
index 0000000..bf22bd3
--- /dev/null
+++ b/src/cappa/completion/shells.py
@@ -0,0 +1,82 @@
+from cappa.completion.types import ShellHandler
+
+shells = [
+    ShellHandler(
+        "bash",
+        template="""\
+_%(safe_prog_name)s_completion() {
+    local IFS=$'\\n' local response
+    response=$(env COMPLETION_LINE="${COMP_WORDS[*]}" COMPLETION_LOCATION=$((COMP_CWORD+1)) $1 --completion=complete)
+
+    for completion in $response; do
+        IFS=':' read value help <<< "$completion"
+
+        if [[ $type == 'file' ]]; then
+            COMPREPLY=()
+            compopt -o default
+        else
+            COMPREPLY+=($value)
+        fi
+    done
+    return 0
+}
+
+_%(safe_prog_name)s_completion_setup() {
+    complete -o nosort -F _%(safe_prog_name)s_completion %(prog_name)s
+}
+
+_%(safe_prog_name)s_completion_setup;
+        """,
+    ),
+    ShellHandler(
+        "zsh",
+        template="""\
+#compdef %(prog_name)s
+
+_%(safe_prog_name)s_completion() {
+    local -a completions
+    local -a response
+    (( ! $+commands[%(prog_name)s] )) && return 1
+
+    response=("${(@f)$(env COMPLETION_LINE="${words[*]}" COMPLETION_LOCATION=$((CURRENT)) \
+%(prog_name)s %(completion_arg)s=complete)}")
+
+    if [[ -n "$completions" ]]; then
+        if [[ "${completions[1]}" == "file" ]]; then
+            _files
+        else
+            _describe 'options' completions
+        fi
+    fi
+}
+
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
+    _%(safe_prog_name)s_completion "$@"
+else
+    compdef _%(safe_prog_name)s_completion %(prog_name)s
+fi
+""",
+    ),
+    ShellHandler(
+        "fish",
+        template="""\
+function _%(safe_prog_name)s_completion
+    set -l response (env COMPLETION_LINE=(commandline -cp) COMPLETION_LOCATION=(math (commandline -C) + 1) \
+%(prog_name)s %(completion_arg)s=complete)
+
+    for completion in $response
+        set -l value (string split --max 1 ":" -- $completion)
+
+        if test $value[1] = "file"
+            __fish_complete_path
+        else
+            echo $value[2]
+        end
+    end
+end
+
+complete --no-files --command %(prog_name)s --arguments "(_%(safe_prog_name)s_completion)"
+""",
+    ),
+]
+available_shells = {s.name: s for s in shells}
diff --git a/src/cappa/completion/types.py b/src/cappa/completion/types.py
new file mode 100644
index 0000000..a99b0b9
--- /dev/null
+++ b/src/cappa/completion/types.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+import dataclasses
+import re
+import typing
+
+if typing.TYPE_CHECKING:
+    from cappa.arg import Arg
+
+
+@dataclasses.dataclass
+class ShellHandler:
+    name: str
+    template: str
+
+    def backend_template(self, prog: str, arg: Arg) -> str:
+        try:
+            prog, _ = prog.rsplit("/", 1)
+        except ValueError:
+            pass
+
+        safe_name = re.sub(r"\W*", "", prog.replace("-", "_"), flags=re.ASCII)
+
+        assert isinstance(arg.long, list)
+        assert len(arg.long) > 0
+        return self.template % {
+            "prog_name": prog,
+            "safe_prog_name": safe_name,
+            "completion_arg": arg.long[0],
+        }
+
+
+@dataclasses.dataclass
+class Completion:
+    value: str | None = None
+    help: str | None = None
+
+
+@dataclasses.dataclass
+class FileCompletion:
+    text: str
+
+
+class CompletionError(RuntimeError):
+    def __init__(
+        self, *completions: Completion | FileCompletion, value="complete", **_
+    ) -> None:
+        self.completions = completions
+        self.value = value
diff --git a/src/cappa/help.py b/src/cappa/help.py
new file mode 100644
index 0000000..1df793c
--- /dev/null
+++ b/src/cappa/help.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+import typing
+from itertools import groupby
+
+from rich.console import NewLine
+from rich.table import Table
+from rich.text import Text
+from typing_extensions import TypeAlias
+
+from cappa.arg import Arg, ArgAction, no_extra_arg_actions
+from cappa.command import Command
+from cappa.output import Displayable
+from cappa.subcommand import Subcommand
+
+ArgGroup: TypeAlias = typing.Tuple[str, typing.List[typing.Union[Arg, Subcommand]]]
+
+
+def create_version_arg(version: str | Arg | None = None) -> Arg | None:
+    if not version:
+        return None
+
+    if isinstance(version, str):
+        version = Arg(
+            version,
+            short=["-v"],
+            long=["--version"],
+            help="Show the version and exit.",
+            group=(3, "Help"),
+        )
+
+    return version.normalize(action=ArgAction.version)
+
+
+def create_help_arg(help: bool | Arg | None = True) -> Arg | None:
+    if not help:
+        return None
+
+    if isinstance(help, bool):
+        help = Arg(
+            name="help",
+            short=["-h"],
+            long=["--help"],
+            help="Show this message and exit.",
+            group=(3, "Help"),
+        )
+
+    return help.normalize(action=ArgAction.help, name="help")
+
+
+def create_completion_arg(completion: bool | Arg = True) -> Arg | None:
+    if not completion:
+        return None
+
+    if isinstance(completion, bool):
+        completion = Arg(
+            name="completion",
+            long=["--completion"],
+            choices=["generate", "complete"],
+            group=(3, "Help"),
+            help="Use `--completion generate` to print shell-specific completion source",
+        ).normalize()
+
+    return completion.normalize(action=ArgAction.completion)
+
+
+def format_help(command: Command, prog: str) -> list[Displayable]:
+    arg_groups = generate_arg_groups(command)
+
+    lines: list[Displayable] = []
+    lines.append(add_short_args(prog, arg_groups))
+
+    desc = " ".join([s for s in [command.help, command.description] if s])
+    if desc:
+        lines.append(NewLine())
+        lines.append(Text(desc))
+
+    lines.extend(add_long_args(arg_groups))
+    return lines
+
+
+def generate_arg_groups(command: Command, include_hidden=False) -> list[ArgGroup]:
+    def by_group(arg: Arg | Subcommand) -> tuple[int, str]:
+        assert isinstance(arg.group, tuple)
+        return typing.cast(typing.Tuple[int, str], arg.group)
+
+    return [
+        (g, [a for a in args if include_hidden or not a.hidden])
+        for (_, g), args in groupby(
+            sorted(command.arguments, key=by_group), key=by_group
+        )
+    ]
+
+
+def add_short_args(prog: str, arg_groups: list[ArgGroup]) -> str:
+    segments: list[str] = [f"Usage: {prog}"]
+    for _, args in arg_groups:
+        for arg in args:
+            segments.append(format_arg_name(arg, ", "))
+
+    return " ".join(segments)
+
+
+def add_long_args(arg_groups: list[ArgGroup]) -> list:
+    table = Table(box=None)
+    table.add_column(style="cappa.arg", justify="right")
+    table.add_column(style="cappa.help")
+
+    for group, args in arg_groups:
+        table.add_row(
+            Text(group, style="cappa.group", justify="right"),
+            Text(style="cappa.group"),
+        )
+        for arg in args:
+            table.add_row(format_arg_name(arg, "/"), arg.help)
+
+        table.add_row()
+
+    return [table]
+
+
+def format_arg_name(arg: Arg | Subcommand, delimiter) -> str:
+    if isinstance(arg, Arg):
+        arg_names = arg.names_str(delimiter)
+        text = f"[cappa.arg]{arg_names}[/cappa.arg]"
+
+        is_option = arg.short or arg.long
+        has_value = arg.action not in no_extra_arg_actions
+        if is_option and has_value:
+            text = f"{text} [cappa.arg.name]{arg.name}[/cappa.arg.name]"
+
+        if not arg.required:
+            return rf"\[{text}]"
+
+        return text
+
+    arg_names = arg.names_str(",")
+    return f"[cappa.subcommand]{arg_names}[/cappa.subcommand]"
diff --git a/src/cappa/invoke.py b/src/cappa/invoke.py
index 642410a..673eb74 100644
--- a/src/cappa/invoke.py
+++ b/src/cappa/invoke.py
@@ -8,9 +8,9 @@
 
 from typing_extensions import get_type_hints
 
-from cappa import output
 from cappa.command import Command, HasCommand
-from cappa.subcommand import Subcommands
+from cappa.output import Exit, Output
+from cappa.subcommand import Subcommand
 from cappa.typing import find_type_annotation
 
 T = typing.TypeVar("T", bound=HasCommand)
@@ -31,6 +31,8 @@ def invoke_callable(
     command: Command,
     parsed_command: Command[T],
     instance: T,
+    *,
+    output: Output,
     deps: typing.Sequence[Callable]
     | typing.Mapping[Callable, Dep | typing.Any]
     | None = None,
@@ -38,6 +40,7 @@ def invoke_callable(
     try:
         fn: Callable = resolve_invoke_handler(parsed_command, instance)
         implicit_deps = resolve_implicit_deps(command, instance)
+        implicit_deps[Output] = output
         fullfilled_deps = resolve_global_deps(deps, implicit_deps)
 
         kwargs = fullfill_deps(fn, fullfilled_deps)
@@ -48,8 +51,8 @@ def invoke_callable(
 
     try:
         return fn(**kwargs)
-    except output.Exit as e:
-        e.print()
+    except Exit as e:
+        output.exit(e)
         raise e
 
 
@@ -125,7 +128,7 @@ def resolve_implicit_deps(command: Command, instance: HasCommand) -> dict:
     deps = {instance.__class__: instance}
 
     for arg in command.arguments:
-        if not isinstance(arg, Subcommands):
+        if not isinstance(arg, Subcommand):
             # Args do not produce dependencies themselves.
             continue
 
diff --git a/src/cappa/output.py b/src/cappa/output.py
index 16c04e5..b88607f 100644
--- a/src/cappa/output.py
+++ b/src/cappa/output.py
@@ -1,31 +1,104 @@
 from __future__ import annotations
 
+import io
 import sys
 import typing
+from dataclasses import dataclass
 
-from cappa.rich import print as rich_print
+from rich.console import Console, NewLine
+from rich.prompt import Confirm, Prompt
+from rich.table import Table
+from rich.text import Text
+from rich.theme import Theme
+from typing_extensions import TypeAlias
 
+prompt_types = (Prompt, Confirm)
 
-class Exit(SystemExit):
-    def __init__(self, message: str | None = None, *, code: str | int | None = 0):
-        self.message = message
-        super().__init__(code)
 
-    def print(self):
-        if self.message:
-            print(self.message, flush=True)
+Displayable: TypeAlias = typing.Union[str, Text, Table, NewLine]
+
+
+@dataclass
+class Output:
+    output_console: Console
+    error_console: Console
+    theme: typing.ClassVar[Theme] = Theme(
+        {
+            "cappa.prog": "grey50",
+            "cappa.group": "dark_orange bold",
+            "cappa.arg": "cyan",
+            "cappa.arg.name": "dark_cyan",
+            "cappa.subcommand": "dark_cyan",
+            "cappa.help": "default",
+        }
+    )
+
+    @classmethod
+    def from_theme(cls, theme: Theme | None = None):
+        output_console = Console(file=sys.stdout, theme=theme or cls.theme)
+        error_console = Console(file=sys.stderr, theme=theme or cls.theme)
+        return cls(output_console, error_console)
+
+    def exit(self, e: Exit):
+        if e.code == 0:
+            self.output(e)
+        else:
+            self.error(e)
+
+    def output(self, message: list[Displayable] | Displayable | Exit | None):
+        self.write(self.output_console, message)
+
+    def error(self, message: list[Displayable] | Displayable | Exit | None):
+        self.write(self.error_console, message)
+
+    def write(
+        self, console: Console, message: list[Displayable] | Displayable | Exit | None
+    ):
+        if isinstance(message, Exit):
+            message = message.message
+
+        if message is None:
+            return
 
+        if isinstance(message, list):
+            messages = message
+        else:
+            messages = [message]
 
-def print(message: str, rich: typing.Any = rich_print, flush=False):
-    if rich and rich_print:
-        rich_print(message, file=sys.stderr, flush=flush)
-    else:
-        sys.stderr.write(message + "\n")
-        if flush:
-            sys.stderr.flush()
+        for m in messages:
+            console.print(m)
 
 
-__all__ = [
-    "Exit",
-    "print",
-]
+class TestPrompt(Prompt):
+    def __init__(self, prompt, *, input, default=..., **kwargs):
+        self.file = io.StringIO()
+        self.default = default
+        self.stream = io.StringIO(input)
+
+        console = Console(file=self.file)
+        super().__init__(prompt=prompt, console=console, **kwargs)
+
+    def __call__(self):
+        return super().__call__(default=self.default, stream=self.stream)
+
+
+class Exit(SystemExit):
+    def __init__(
+        self,
+        message: list[Displayable] | Displayable | None = None,
+        *,
+        code: str | int | None = 0,
+    ):
+        self.message = message
+        super().__init__(code)
+
+
+class HelpExit(Exit):
+    def __init__(
+        self,
+        message: list[Displayable] | Displayable,
+        *,
+        code: str | int | None = 0,
+    ):
+        super().__init__(code=code)
+        self.message = message
diff --git a/src/cappa/parser.py b/src/cappa/parser.py
new file mode 100644
index 0000000..da10e31
--- /dev/null
+++ b/src/cappa/parser.py
@@ -0,0 +1,491 @@
+from __future__ import annotations
+
+import dataclasses
+import os
+import typing
+from collections import deque
+
+from cappa.arg import Arg, ArgAction, no_extra_arg_actions
+from cappa.command import Command, Subcommand
+from cappa.completion.types import Completion, CompletionError, FileCompletion
+from cappa.help import (
+    create_completion_arg,
+    create_help_arg,
+    create_version_arg,
+    format_help,
+)
+from cappa.output import Exit, HelpExit
+from cappa.typing import T, assert_type
+
+
+class BadArgumentError(RuntimeError):
+    def __init__(
+        self,
+        message: str,
+        *,
+        value,
+        command: Command,
+        arg: Arg | Subcommand | None = None,
+    ) -> None:
+        super().__init__(message)
+        self.value = value
+        self.command = command
+        self.arg = arg
+
+
+class HelpAction(RuntimeError):
+    def __init__(self, *, command, **_):
+        self.command = command
+
+
+class VersionAction(RuntimeError):
+    def __init__(self, *, arg, **_):
+        self.version = arg
+
+
+def backend(
+    command: Command[T],
+    argv: list[str],
+    color: bool = True,
+    version: str | Arg | None = None,
+    help: bool | Arg | None = True,
+    completion: bool | Arg = True,
+    provide_completions: bool = False,
+) -> tuple[typing.Any, Command[T], dict[str, typing.Any]]:
+    if not color:
+        os.environ["NO_COLOR"] = "1"
+
+    try:
+        prog, *argv = argv
+    except ValueError:
+        prog = ""
+
+    args = RawArg.collect(argv, provide_completions=provide_completions)
+
+    help_arg = create_help_arg(help)
+    version_arg = create_version_arg(version)
+    completion_arg = create_completion_arg(completion)
+
+    command.add_meta_actions(
+        help=help_arg, version=version_arg, completion=completion_arg
+    )
+
+    context = ParseContext.from_command(command, args)
+    context.provide_completions = provide_completions
+
+    try:
+        try:
+            parse(context)
+        except HelpAction as e:
+            raise HelpExit(format_help(e.command, prog), code=0)
+        except VersionAction as e:
+            raise Exit(e.version.name, code=0)
+        except BadArgumentError as e:
+            if context.provide_completions and e.arg:
+                completions = e.arg.completion(e.value) if e.arg.completion else []
+                raise CompletionError(*completions)
+
+            format_help(e.command, prog)
+            raise Exit(str(e), code=2)
+    except CompletionError as e:
+        from cappa.completion.base import execute, format_completions
+
+        if context.provide_completions:
+            completions = format_completions(*e.completions)
+            raise Exit(completions, code=0)
+
+        execute(
+            command,
+            prog,
+            e.value,
+            help=help_arg,
+            version=version_arg,
+            completion=assert_type(completion_arg, Arg),
+        )
+
+    if provide_completions:
+        raise Exit(code=0)
+
+    return (context, context.selected_command or command, context.result)
+
+
+@dataclasses.dataclass
+class ParseContext:
+    command: Command
+    remaining_args: deque[RawArg | RawOption]
+    options: dict[str, Arg]
+    arguments: deque[Arg | Subcommand]
+    missing_options: set[str]
+
+    consumed_args: list[RawArg | RawOption] = dataclasses.field(default_factory=list)
+
+    result: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
+    selected_command: Command | None = None
+
+    provide_completions: bool = False
+
+    @classmethod
+    def from_command(
+        cls,
+        command: Command,
+        args: deque[RawArg | RawOption],
+    ) -> ParseContext:
+        options, missing_options = cls.collect_options(command)
+        arguments = deque(cls.collect_arguments(command))
+        return cls(command, args, options, arguments, missing_options=missing_options)
+
+    @staticmethod
+    def collect_options(command: Command) -> tuple[dict[str, Arg], set[str]]:
+        result = {}
+        unique_names = set()
+        for arg in command.arguments:
+            if not isinstance(arg, Arg):
+                continue
+
+            if arg.short or arg.long:
+                if arg.action not in ArgAction.value_actions():
+                    unique_names.add(arg.name)
+                result[arg.name] = arg
+
+            assert arg.short is not True
+            for short in arg.short or []:
+                result[short] = arg
+
+            assert arg.long is not True
+            for long in arg.long or []:
+                result[long] = arg
+
+        return result, unique_names
+
+    @staticmethod
+    def collect_arguments(command: Command) -> list[Arg | Subcommand]:
+        result = []
+        for arg in command.arguments:
+            if (
+                isinstance(arg, Arg)
+                and not arg.short
+                and not arg.long
+                or isinstance(arg, Subcommand)
+            ):
+                result.append(arg)
+        return result
+
+    def has_values(self) -> bool:
+        return bool(self.remaining_args)
+
+    def peek_value(self):
+        if not self.remaining_args:
+            return None
+        return self.remaining_args[0]
+
+    def next_value(self):
+        arg = self.remaining_args.popleft()
+        self.consumed_args.append(arg)
+        return arg
+
+    def next_argument(self):
+        return self.arguments.popleft()
+
+
+@dataclasses.dataclass(frozen=True)
+class RawArg:
+    raw: str
+
+    @classmethod
+    def collect(
+        cls, argv: list[str], *, provide_completions: bool = False
+    ) -> deque[RawArg | RawOption]:
+        result = []
+
+        encountered_double_dash = False
+        for arg in argv:
+            if encountered_double_dash:
+                item: RawArg | RawOption | None = cls(arg)
+            else:
+                item = RawArg.from_str(arg, provide_completions=provide_completions)
+
+            if item is None:
+                encountered_double_dash = True
+                continue
+
+            result.append(item)
+
+        return deque(result)
+
+    @classmethod
+    def from_str(
+        cls, arg: str, *, provide_completions: bool = False
+    ) -> RawArg | RawOption | None:
+        skip = arg == "--" and not provide_completions
+        if skip:
+            return None
+
+        is_option = arg and arg[0] == "-" and (provide_completions or len(arg) > 1)
+
+        if is_option:
+            return RawOption.from_str(arg)
+
+        return cls(arg)
+
+
+@dataclasses.dataclass(frozen=True)
+class RawOption:
+    name: str
+    is_long: bool
+    value: str | None = None
+
+    @classmethod
+    def from_str(cls, arg: str) -> RawOption:
+        is_long = arg.startswith("--")
+
+        name = arg
+        value = None
+        if is_long:
+            is_explicit = "=" in arg
+            if is_explicit:
+                name, value = arg.split("=")
+        return cls(name=name, is_long=is_long, value=value)
+
+
+def parse(context: ParseContext) -> None:
+    while True:
+        while isinstance(context.peek_value(), RawOption):
+            arg = typing.cast(RawOption, context.next_value())
+
+            if arg.is_long:
+                parse_option(context, arg)
+            else:
+                parse_short_option(context, arg)
+
+        parse_args(context)
+
+        if not context.has_values():
+            break
+
+    # Options are not explicitly iterated over because they can occur multi times non-contiguouesly.
+    # So instead we check afterward, if there are any missing which we haven't yet fulfilled.
+    for opt_name in context.missing_options:
+        opt = context.options[opt_name]
+        if opt.required:
+            raise BadArgumentError(
+                f"The following arguments are required: {opt.names_str()}",
+                value="",
+                command=context.command,
+                arg=opt,
+            )
+
+
+def parse_option(context: ParseContext, raw: RawOption) -> None:
+    if raw.name not in context.options:
+        possible_values = [
+            name for name in context.options if name.startswith(raw.name)
+        ]
+
+        if context.provide_completions:
+            options = [
+                Completion(option, help=context.options[option].help)
+                for option in possible_values
+            ]
+            raise CompletionError(*options)
+
+        message = f"Unrecognized arguments: {raw.name}"
+        if possible_values:
+            message += f" (Did you mean: {', '.join(possible_values)})"
+
+        raise BadArgumentError(message, value=raw.name, command=context.command)
+
+    arg = context.options[raw.name]
+    consume_arg(context, arg, raw)
+
+
+def parse_short_option(context: ParseContext, arg: RawOption) -> None:
+    if arg.name == "-" and context.provide_completions:
+        return parse_option(context, arg)
+
+    for ch in arg.name[1:]:
+        opt = RawOption(f"-{ch}", is_long=True)
+        parse_option(context, opt)
+    return None
+
+
+def parse_args(context: ParseContext) -> None:
+    while context.arguments:
+        if isinstance(context.peek_value(), RawOption):
+            break
+
+        arg = context.next_argument()
+
+        if isinstance(arg, Subcommand):
+            consume_subcommand(context, arg)
+        else:
+            consume_arg(context, arg)
+    else:
+        value = context.peek_value()
+        if value is None or isinstance(value, RawOption):
+            return
+
+        raw_values = []
+        while context.peek_value():
+            raw = typing.cast(RawArg, context.next_value()).raw
+            raw_values.append(raw)
+
+        raise BadArgumentError(
+            f"Unrecognized arguments: {', '.join(raw_values)}",
+            value=raw_values,
+            command=context.command,
+        )
+
+
+def consume_arg(
+    context: ParseContext, arg: Arg, option: RawOption | None = None
+) -> typing.Any:
+    orig_num_args = arg.num_args or 1
+    num_args = orig_num_args
+
+    if arg.action in no_extra_arg_actions:
+        orig_num_args = 0
+        num_args = 0
+
+    result: list[str] | str
+    if option and option.value:
+        result = [option.value]
+    else:
+        result = []
+        while num_args:
+            try:
+                value = context.next_value()
+            except IndexError:
+                break
+
+            if isinstance(value, RawOption):
+                raise BadArgumentError(
+                    f"Argument requires {orig_num_args} values, "
+                    f"only found {len(result)} ('{' '.join(result)}' so far).",
+                    value=result,
+                    command=context.command,
+                    arg=arg,
+                )
+
+            result.append(value.raw)
+
+            # If num-args starts at -1, then it will always be truthy when we subtract
+            # from it. I.e. it has unbounded length, like we want.
+            num_args -= 1
+
+    if orig_num_args == 1:
+        if result:
+            result = result[0]
+            if arg.choices and result not in arg.choices:
+                choices = ", ".join(f"'{c}'" for c in arg.choices)
+                raise BadArgumentError(
+                    f"Invalid choice: '{result}' (choose from {choices})",
+                    value=result,
+                    command=context.command,
+                    arg=arg,
+                )
+
+            if context.provide_completions and not context.has_values():
+                raise CompletionError(FileCompletion(result))
+        else:
+            if not option and not arg.required:
+                return
+
+            raise BadArgumentError(
+                f"Option '{arg.name}' requires an argument.",
+                value="",
+                command=context.command,
+                arg=arg,
+            )
+
+    if option and arg.name in context.missing_options:
+        context.missing_options.remove(arg.name)
+
+    action = typing.cast(ArgAction, arg.action)
+    name = typing.cast(str, arg.name)
+    action_handler = process_options[action]
+    existing_result = context.result.get(name)
+    context.result[name] = action_handler(
+        command=context.command,
+        arg=arg,
+        option_name=option and option.name,
+        existing=existing_result,
+        value=result,
+    )
+
+
+def consume_subcommand(context: ParseContext, arg: Subcommand) -> typing.Any:
+    try:
+        value = context.next_value()
+    except IndexError:
+        if not arg.required:
+            return
+
+        raise BadArgumentError(
+            f"The following arguments are required: {arg.names_str()}",
+            value="",
+            command=context.command,
+            arg=arg,
+        )
+
+    assert isinstance(value, RawArg), value
+    if value.raw not in arg.options:
+        raise BadArgumentError(
+            "invalid subcommand", value=value.raw, command=context.command, arg=arg
+        )
+
+    command = arg.options[value.raw]
+    context.selected_command = command
+
+    nested_context = ParseContext.from_command(command, context.remaining_args)
+    nested_context.provide_completions = context.provide_completions
+    nested_context.result["__name__"] = value.raw
+
+    parse(nested_context)
+
+    name = typing.cast(str, arg.name)
+    context.result[name] = nested_context.result
+    if nested_context.selected_command:
+        context.selected_command = nested_context.selected_command
+
+
+def raises(exc):
+    def store(**value):
+        raise exc(**value)
+
+    return store
+
+
+def store_bool(val: bool):
+    def store(arg, option_name: str, **_):
+        long = assert_type(arg.long, list)
+        has_no_option = any("--no-" in i for i in long)
+        if has_no_option:
+            return not option_name.startswith("--no-")
+        return val
+
+    return store
+
+
+def store_count(*, existing: int | None, **_):
+    return (existing or 0) + 1
+
+
+def store_set(*, value, **_):
+    return value
+
+
+def store_append(*, existing, value, **_):
+    return (existing or []) + [value]
+
+
+process_options = {
+    ArgAction.help: raises(HelpAction),
+    ArgAction.version: raises(VersionAction),
+    ArgAction.completion: raises(CompletionError),
+    ArgAction.set: store_set,
+    None: store_set,
+    ArgAction.store_true: store_bool(True),
+    ArgAction.store_false: store_bool(False),
+    ArgAction.count: store_count,
+    ArgAction.append: store_append,
+}
diff --git a/src/cappa/rich.py b/src/cappa/rich.py
deleted file mode 100644
index 04e6ab5..0000000
--- a/src/cappa/rich.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from __future__ import annotations
-
-try:
-    import rich
-    import rich.prompt
-    from rich.console import Console
-except ImportError:  # pragma: no cover
-    print = None
-    prompt_types: tuple[type, ...] = ()
-else:
-    import io
-
-    print = rich.print
-    prompt_types = (rich.prompt.Prompt, rich.prompt.Confirm)
-
-    class TestPrompt(rich.prompt.Prompt):
-        def __init__(self, prompt, *, input, default=..., **kwargs):
-            self.file = io.StringIO()
-            self.default = default
-            self.stream = io.StringIO(input)
-
-            console = Console(file=self.file)
-            super().__init__(prompt=prompt, console=console, **kwargs)
-
-        def __call__(self):
-            return super().__call__(default=self.default, stream=self.stream)
diff --git a/src/cappa/subcommand.py b/src/cappa/subcommand.py
index c53fb94..8aa833f 100644
--- a/src/cappa/subcommand.py
+++ b/src/cappa/subcommand.py
@@ -7,7 +7,15 @@
 from typing_inspect import is_optional_type, is_union_type
 
 from cappa.class_inspect import Field, extract_dataclass_metadata
-from cappa.typing import MISSING, T, find_type_annotation
+from cappa.completion.types import Completion
+from cappa.typing import (
+    MISSING,
+    NoneType,
+    T,
+    assert_type,
+    find_type_annotation,
+    missing,
+)
 
 if typing.TYPE_CHECKING:
     from cappa.command import Command
@@ -26,12 +34,17 @@ class Subcommand:
         types: Defaults to the class's annotated types, but can be overridden here.
         required: Defaults to automatically inferring requiredness, based on whether the
             class's value has a default. By setting this, you can force a particular value.
+        hidden: Whether the argument should be hidden in help text. Defaults to False.
     """
 
     name: str | MISSING = ...
     help: str = ""
-    types: typing.Iterable[type] | MISSING = ...
     required: bool | None = None
+    group: str | tuple[int, str] = (3, "Subcommands")
+    hidden: bool = False
+
+    types: typing.Iterable[type] | MISSING = ...
+    options: dict[str, Command] = dataclasses.field(default_factory=dict)
 
     @classmethod
     def collect(cls, field: Field, type_hint: type) -> Self | None:
@@ -46,41 +59,86 @@ def collect(cls, field: Field, type_hint: type) -> Self | None:
         if subcommand is None:
             return None
 
-        has_none = False
-        kwargs: dict[str, typing.Any] = {}
-        if subcommand.types is ...:
-            if is_union_type(annotation):
-                types = typing.get_args(annotation)
-                if is_optional_type(annotation):
-                    has_none = True
-                    types = tuple([t for t in types if not is_optional_type(t)])
-            else:
-                types = (annotation,)
+        return subcommand.normalize(annotation, name=field.name)
+
+    def normalize(self, annotation=NoneType, name: str | None = None) -> Self:
+        name = name or assert_type(self.name, str)
+        types = infer_types(self, annotation)
+        required = infer_required(self, annotation)
+        options = infer_options(self, types)
+        group = infer_group(self)
 
-            kwargs["types"] = types
+        return dataclasses.replace(
+            self,
+            name=name,
+            types=types,
+            required=required,
+            options=options,
+            group=group,
+        )
+
+    def map_result(self, parsed_args):
+        option_name = parsed_args.pop("__name__")
+        option = self.options[option_name]
+        return option.map_result(option, parsed_args)
 
-        if subcommand.required is None:
-            kwargs["required"] = not has_none
+    def names(self) -> list[str]:
+        return list(self.options.keys())
 
-        kwargs["name"] = field.name
+    def names_str(self, delimiter: str = ", ") -> str:
+        return f"{{{delimiter.join(self.names())}}}"
 
-        return dataclasses.replace(subcommand, **kwargs)
+    def completion(self, partial: str):
+        return [Completion(o) for o in self.options if partial in o]
 
 
-@dataclasses.dataclass
-class Subcommands(typing.Generic[T]):
-    name: str
-    options: dict[str, Command]
-    required: bool = False
-    help: str | None = None
+def infer_types(arg: Subcommand, annotation: type) -> typing.Iterable[type]:
+    if arg.types is not missing:
+        return typing.cast(typing.Iterable[type], arg.types)
 
-    def normalize(self):
-        return self
+    if is_union_type(annotation):
+        types = typing.get_args(annotation)
+        return tuple([t for t in types if not is_optional_type(t)])
 
-    def map_result(self, parsed_args):
-        option_name = parsed_args.pop("__name__")
-        option = self.options[option_name]
-        return option.map_result(option, parsed_args)
+    return (annotation,)
+
+
+def infer_required(arg: Subcommand, annotation: type) -> bool:
+    if arg.required is not None:
+        return arg.required
+
+    return not is_optional_type(annotation)
+
+
+def infer_options(arg: Subcommand, types: typing.Iterable[type]) -> dict[str, Command]:
+    from cappa.command import Command
+
+    if arg.options:
+        return {
+            name: Command.collect(type_command)
+            for name, type_command in arg.options.items()
+        }
+
+    options = {}
+    for type_ in types:
+        type_command: Command = Command.get(type_)
+        type_name = type_command.real_name()
+        options[type_name] = Command.collect(type_command)
+
+    return options
+
+
+def infer_group(arg: Subcommand) -> str | tuple[int, str]:
+    group: str | tuple[int, str] | MISSING = arg.group
+    group_name = None
+    if isinstance(group, str):
+        group_name = group
+        group = missing
+
+    if group is missing:
+        return (3, group_name or "Subcommands")
+
+    return typing.cast(typing.Tuple[int, str], group)
 
 
-Subcmd: TypeAlias = Annotated[T, Subcommand]
+Subcommands: TypeAlias = Annotated[T, Subcommand]
diff --git a/src/cappa/typing.py b/src/cappa/typing.py
index 265c862..288aab2 100644
--- a/src/cappa/typing.py
+++ b/src/cappa/typing.py
@@ -41,16 +41,16 @@ def find_type_annotation(
 
 
 def assert_not_missing(value: T | MISSING) -> T:
-    assert not isinstance(value, MISSING)
+    assert not isinstance(value, MISSING), value
     return typing.cast(T, value)
 
 
 def assert_type(value: typing.Any, typ: type[T]) -> T:
-    assert isinstance(value, typ)
+    assert isinstance(value, typ), value
     return typing.cast(T, value)
 
 
-def render_type(typ) -> str:
+def backend_type(typ) -> str:
     if is_literal_type(typ):
         return typing.get_args(typ)[0]
 


Test patch:
diff --git a/src/cappa/testing.py b/src/cappa/testing.py
index be9512e..4dceb98 100644
--- a/src/cappa/testing.py
+++ b/src/cappa/testing.py
@@ -18,8 +18,7 @@ class RunnerArgs(typing.TypedDict, total=False):
 
     obj: type
     deps: typing.Sequence[typing.Callable]
-    render: typing.Callable
-    exit_with: typing.Callable
+    backend: typing.Callable | None
     color: bool
     version: str | cappa.Arg
     help: bool | cappa.Arg
@@ -70,8 +69,7 @@ class CommandRunner:
 
     obj: type | None = None
     deps: typing.Sequence[typing.Callable] | None = None
-    render: typing.Callable | None = None
-    exit_with: typing.Callable | None = None
+    backend: typing.Callable | None = None
     color: bool = True
     version: str | cappa.Arg | None = None
     help: bool | cappa.Arg = True
@@ -82,8 +80,7 @@ def coalesce_args(self, *args: str, **kwargs: Unpack[RunnerArgs]) -> dict:
         return {
             "argv": self.base_args + list(args),
             "obj": kwargs.get("obj") or self.obj,
-            "render": kwargs.get("render") or self.render,
-            "exit_with": kwargs.get("exit_with") or self.exit_with,
+            "backend": kwargs.get("backend") or self.backend,
             "color": kwargs.get("color") or self.color,
             "version": kwargs.get("version") or self.version,
             "help": kwargs.get("help") or self.help,
diff --git a/tests/arg/test_bool.py b/tests/arg/test_bool.py
index 6317dbd..cd0a9f7 100644
--- a/tests/arg/test_bool.py
+++ b/tests/arg/test_bool.py
@@ -6,70 +6,78 @@
 import pytest
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_default():
+@backends
+def test_default(backend):
     @dataclass
     class ArgTest:
         default: bool = False
 
-    test = parse(ArgTest)
+    test = parse(ArgTest, backend=backend)
     assert test.default is False
 
-    test = parse(ArgTest, "--default")
+    test = parse(ArgTest, "--default", backend=backend)
     assert test.default is True
 
 
-def test_explicit_short():
+@backends
+def test_explicit_short(backend):
     @dataclass
     class ArgTest:
-        explicit_short: Annotated[bool, cappa.Arg(short="-es")] = False
+        explicit_short: Annotated[bool, cappa.Arg(short="-e")] = False
 
-    test = parse(ArgTest)
+    test = parse(ArgTest, backend=backend)
     assert test.explicit_short is False
 
-    test = parse(ArgTest, "-es")
+    test = parse(ArgTest, "-e", backend=backend)
     assert test.explicit_short is True
 
 
-def test_explicit_long():
+@backends
+def test_explicit_long(backend):
     @dataclass
     class ArgTest:
         explicit_long: Annotated[bool, cappa.Arg(long="--meow")] = False
 
-    test = parse(ArgTest)
+    test = parse(ArgTest, backend=backend)
     assert test.explicit_long is False
 
-    test = parse(ArgTest, "--meow")
+    test = parse(ArgTest, "--meow", backend=backend)
     assert test.explicit_long is True
 
 
-def test_store_false():
+@backends
+def test_store_false(backend):
     @dataclass
     class ArgTest:
         default_true: Annotated[bool, cappa.Arg(long="--false")] = True
 
-    test = parse(ArgTest)
+    test = parse(ArgTest, backend=backend)
     assert test.default_true is True
 
-    test = parse(ArgTest, "--false")
+    test = parse(ArgTest, "--false", backend=backend)
     assert test.default_true is False
 
 
-def test_true_false_option():
+@backends
+def test_true_false_option(backend):
     @dataclass
     class ArgTest:
         true_false: Annotated[bool, cappa.Arg(long="--true/--no-true")] = True
 
-    test = parse(ArgTest)
+    test = parse(ArgTest, backend=backend)
     assert test.true_false is True
 
-    test = parse(ArgTest, "--true")
+    test = parse(ArgTest, "--true", backend=backend)
     assert test.true_false is True
 
-    test = parse(ArgTest, "--no-true")
+    test = parse(ArgTest, "--no-true", backend=backend)
     assert test.true_false is False
 
-    with pytest.raises(ValueError, match="unrecognized arguments: --garbage"):
-        parse(ArgTest, "--garbage")
+    with pytest.raises(cappa.Exit) as e:
+        parse(ArgTest, "--garbage", backend=backend)
+
+    assert e.value.code == 2
+    assert "unrecognized arguments: --garbage" in str(e.value.message).lower()
diff --git a/tests/arg/test_choices.py b/tests/arg/test_choices.py
index 4f94a55..2683b13 100644
--- a/tests/arg/test_choices.py
+++ b/tests/arg/test_choices.py
@@ -6,10 +6,11 @@
 import pytest
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_manually_specified_choices():
+@backends
+def test_manually_specified_choices(backend):
     @dataclass
     class ArgTest:
         choice: Annotated[str, cappa.Arg(choices=["a", "1"])]
@@ -20,8 +21,8 @@ class ArgTest:
     result = parse(ArgTest, "1")
     assert result.choice == "1"
 
-    with pytest.raises(
-        ValueError,
-        match=r"argument choice: invalid choice: 'two' \(choose from 'a', '1'\)",
-    ):
-        parse(ArgTest, "two")
+    with pytest.raises(cappa.Exit) as e:
+        parse(ArgTest, "two", backend=backend)
+
+    message = str(e.value.message).lower()
+    assert "invalid choice: 'two' (choose from 'a', '1')" in message
diff --git a/tests/arg/test_count.py b/tests/arg/test_count.py
index fc09df1..9b0fd82 100644
--- a/tests/arg/test_count.py
+++ b/tests/arg/test_count.py
@@ -5,28 +5,30 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_count_action():
+@backends
+def test_count_action(backend):
     @dataclass
     class ArgTest:
         arg: Annotated[int, cappa.Arg(short=True, action=cappa.ArgAction.count)]
 
-    result = parse(ArgTest, "-a")
+    result = parse(ArgTest, "-a", backend=backend)
     assert result.arg == 1
 
-    result = parse(ArgTest, "-aaa")
+    result = parse(ArgTest, "-aaa", backend=backend)
     assert result.arg == 3
 
 
-def test_count_option():
+@backends
+def test_count_option(backend):
     @dataclass
     class ArgTest:
         arg: Annotated[int, cappa.Arg(short=True, count=True)]
 
-    result = parse(ArgTest, "-a")
+    result = parse(ArgTest, "-a", backend=backend)
     assert result.arg == 1
 
-    result = parse(ArgTest, "-aaa")
+    result = parse(ArgTest, "-aaa", backend=backend)
     assert result.arg == 3
diff --git a/tests/arg/test_dataclass_metadata_invalid.py b/tests/arg/test_dataclass_metadata_invalid.py
index c13d402..10e6107 100644
--- a/tests/arg/test_dataclass_metadata_invalid.py
+++ b/tests/arg/test_dataclass_metadata_invalid.py
@@ -5,7 +5,7 @@
 import cappa
 import pytest
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -19,9 +19,10 @@ class Invalid:
     a: int = field(metadata={"cappa": 4})
 
 
-def test_valid():
+@backends
+def test_valid(backend):
     with pytest.raises(
         ValueError,
         match='`metadata={"cappa": <x>}` must be of type `Arg` or `Subcommand`',
     ):
-        parse(Command, "-n", "foo", "subcommand", "4")
+        parse(Command, "-n", "foo", "subcommand", "4", backend=backend)
diff --git a/tests/arg/test_default.py b/tests/arg/test_default.py
index bf72242..6cb0e32 100644
--- a/tests/arg/test_default.py
+++ b/tests/arg/test_default.py
@@ -5,7 +5,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -14,6 +14,7 @@ class Command:
     bar: Annotated[int, cappa.Arg(default=5)]
 
 
-def test_valid():
-    test = parse(Command, "1", "2")
+@backends
+def test_valid(backend):
+    test = parse(Command, "1", "2", backend=backend)
     assert test == Command(1, 2)
diff --git a/tests/arg/test_enum.py b/tests/arg/test_enum.py
index 3614a8b..e123ed4 100644
--- a/tests/arg/test_enum.py
+++ b/tests/arg/test_enum.py
@@ -3,9 +3,10 @@
 import enum
 from dataclasses import dataclass
 
+import cappa
 import pytest
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 class Options(enum.Enum):
@@ -19,14 +20,16 @@ class ArgTest:
     options: Options
 
 
-def test_valid():
-    test = parse(ArgTest, "two")
+@backends
+def test_valid(backend):
+    test = parse(ArgTest, "two", backend=backend)
     assert test.options is Options.two
 
 
-def test_invalid():
-    with pytest.raises(
-        ValueError,
-        match=r"argument options: invalid choice: 'thename' \(choose from 'one', 'two', 'three'\)",
-    ):
-        parse(ArgTest, "thename")
+@backends
+def test_invalid(backend):
+    with pytest.raises(cappa.Exit) as e:
+        parse(ArgTest, "thename", backend=backend)
+
+    message = str(e.value.message).lower()
+    assert "invalid choice: 'thename' (choose from 'one', 'two', 'three')" in message
diff --git a/tests/arg/test_env.py b/tests/arg/test_env.py
index ddd972a..1fa0290 100644
--- a/tests/arg/test_env.py
+++ b/tests/arg/test_env.py
@@ -6,41 +6,45 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_missing_fallback():
+@backends
+def test_missing_fallback(backend):
     @dataclass
     class ArgTest:
         default: Annotated[str, cappa.Arg(default=cappa.Env("ASDF", default="wat"))]
 
-    test = parse(ArgTest)
+    test = parse(ArgTest, backend=backend)
     assert test.default == "wat"
 
 
-def test_has_env_var():
+@backends
+def test_has_env_var(backend):
     @dataclass
     class ArgTest:
         default: Annotated[str, cappa.Arg(default=cappa.Env("ASDF", default="wat"))]
 
     with patch("os.environ", new={"ASDF": "asdf!"}):
-        test = parse(ArgTest)
+        test = parse(ArgTest, backend=backend)
     assert test.default == "asdf!"
 
 
-def test_env_defers_to_real_value():
+@backends
+def test_env_defers_to_real_value(backend):
     @dataclass
     class ArgTest:
         default: Annotated[str, cappa.Arg(default=cappa.Env("ASDF", default="wat"))]
 
-    test = parse(ArgTest, "test")
+    test = parse(ArgTest, "test", backend=backend)
     assert test.default == "test"
 
 
-def test_mapping_is_applied():
+@backends
+def test_mapping_is_applied(backend):
     @dataclass
     class ArgTest:
         default: Annotated[bool, cappa.Arg(default=cappa.Env("ASDF", default="wat"))]
 
-    test = parse(ArgTest)
+    test = parse(ArgTest, backend=backend)
     assert test.default is True
diff --git a/tests/arg/test_explicit_subcommand_types.py b/tests/arg/test_explicit_subcommand_types.py
index 4c7dd10..2ba141b 100644
--- a/tests/arg/test_explicit_subcommand_types.py
+++ b/tests/arg/test_explicit_subcommand_types.py
@@ -6,7 +6,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -24,6 +24,7 @@ class Command:
     subcommand: Annotated[Union[Sc1, Sc2], cappa.Subcommand(types=[Sc1, Sc2])]
 
 
-def test_required_missing():
-    parse(Command, "sc1")
-    parse(Command, "sc2")
+@backends
+def test_required_missing(backend):
+    parse(Command, "sc1", backend=backend)
+    parse(Command, "sc2", backend=backend)
diff --git a/tests/arg/test_help.py b/tests/arg/test_help.py
index 86b85df..6a5d570 100644
--- a/tests/arg/test_help.py
+++ b/tests/arg/test_help.py
@@ -5,23 +5,26 @@
 
 import cappa
 import pytest
+from cappa.output import Exit
 from typing_extensions import Annotated
 
 from tests.utils import parse
 
 
+@pytest.mark.help
 def test_explicit_parse_function(capsys):
     @dataclass
     class ArgTest:
         numbers: Annotated[int, cappa.Arg(parse=int, help="example")]
 
-    with pytest.raises(ValueError):
+    with pytest.raises(Exit):
         parse(ArgTest, "--help")
 
     stdout = capsys.readouterr().out
     assert "numbers     example" in stdout
 
 
+@pytest.mark.help
 def test_choices_in_help(capsys):
     @dataclass
     class ArgTest:
@@ -32,7 +35,7 @@ class ArgTest:
     result = parse(ArgTest, "1")
     assert result == ArgTest(1)
 
-    with pytest.raises(ValueError):
+    with pytest.raises(Exit):
         parse(ArgTest, "--help")
 
     stdout = capsys.readouterr().out
diff --git a/tests/arg/test_list.py b/tests/arg/test_list.py
index abdd1e0..a557b05 100644
--- a/tests/arg/test_list.py
+++ b/tests/arg/test_list.py
@@ -6,10 +6,11 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_list_option():
+@backends
+def test_list_option(backend):
     @dataclass
     class ArgTest:
         variable_number: Annotated[List[str], cappa.Arg(short=True, long=True)] = field(
@@ -22,11 +23,13 @@ class ArgTest:
         "one",
         "--variable-number",
         "two",
+        backend=backend,
     )
     assert test.variable_number == ["one", "two"]
 
 
-def test_list_positional():
+@backends
+def test_list_positional(backend):
     @dataclass
     class ArgTest:
         variable_number: List[str] = field(default_factory=list)
@@ -36,5 +39,6 @@ class ArgTest:
         "one",
         "two",
         "three",
+        backend=backend,
     )
     assert test.variable_number == ["one", "two", "three"]
diff --git a/tests/arg/test_literal.py b/tests/arg/test_literal.py
index 4607320..6828568 100644
--- a/tests/arg/test_literal.py
+++ b/tests/arg/test_literal.py
@@ -3,9 +3,10 @@
 from dataclasses import dataclass
 from typing import Literal, Union
 
+import cappa
 import pytest
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -13,19 +14,24 @@ class ArgTest:
     name: Union[Literal["one"], Literal["two"], Literal["three"], Literal[4]]
 
 
-def test_valid():
-    test = parse(ArgTest, "two")
+@backends
+def test_valid(backend):
+    test = parse(ArgTest, "two", backend=backend)
     assert test.name == "two"
 
 
-def test_valid_int():
-    test = parse(ArgTest, "4")
+@backends
+def test_valid_int(backend):
+    test = parse(ArgTest, "4", backend=backend)
     assert test.name == 4
 
 
-def test_invalid():
-    with pytest.raises(
-        ValueError,
-        match=r"invalid choice: 'thename' \(choose from 'one', 'two', 'three', '4'\)",
-    ):
-        parse(ArgTest, "thename")
+@backends
+def test_invalid(backend):
+    with pytest.raises(cappa.Exit) as e:
+        parse(ArgTest, "thename", backend=backend)
+
+    message = str(e.value.message).lower()
+    assert (
+        "invalid choice: 'thename' (choose from 'one', 'two', 'three', '4')" in message
+    )
diff --git a/tests/arg/test_literal_intermixed_union.py b/tests/arg/test_literal_intermixed_union.py
index 2e5c0d6..25d4086 100644
--- a/tests/arg/test_literal_intermixed_union.py
+++ b/tests/arg/test_literal_intermixed_union.py
@@ -3,9 +3,10 @@
 from dataclasses import dataclass
 from typing import Literal, Union
 
+import cappa
 import pytest
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -13,19 +14,26 @@ class ArgTest:
     name: Union[int, Literal["one"]]
 
 
-def test_valid_int():
-    test = parse(ArgTest, "5")
+@backends
+def test_valid_int(backend):
+    test = parse(ArgTest, "5", backend=backend)
     assert test.name == 5
 
 
-def test_valid_literal():
-    test = parse(ArgTest, "one")
+@backends
+def test_valid_literal(backend):
+    test = parse(ArgTest, "one", backend=backend)
     assert test.name == "one"
 
 
-def test_invalid_string():
-    with pytest.raises(
-        ValueError,
-        match=r"Could not parse 'thename' given options: <int>, one",
-    ):
-        parse(ArgTest, "thename")
+@backends
+def test_invalid_string(backend):
+    with pytest.raises(cappa.Exit) as e:
+        parse(ArgTest, "thename", backend=backend)
+
+    assert e.value.code == 2
+
+    assert e.value.message == (
+        "Invalid value for 'name' with value 'thename': "
+        "Could not parse 'thename' given options: <int>, one"
+    )
diff --git a/tests/arg/test_literal_sequence.py b/tests/arg/test_literal_sequence.py
index 2c82564..122871a 100644
--- a/tests/arg/test_literal_sequence.py
+++ b/tests/arg/test_literal_sequence.py
@@ -3,47 +3,51 @@
 from dataclasses import dataclass
 from typing import List, Literal, Set, Tuple, Union
 
+import cappa
 import pytest
-from cappa import Arg
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
 class ArgTest:
     list: Annotated[
         List[Union[Literal["one"], Literal["two"], Literal["three"], Literal[4]]],
-        Arg(short=True, default=[]),
+        cappa.Arg(short=True, default=[]),
     ]
     tuple: Annotated[
         Tuple[Union[Literal["one"], Literal["two"], Literal["three"], Literal[4]], ...],
-        Arg(short=True, default=()),
+        cappa.Arg(short=True, default=()),
     ]
     set: Annotated[
         Set[Union[Literal["one"], Literal["two"], Literal["three"], Literal[4]]],
-        Arg(short=True, default=set()),
+        cappa.Arg(short=True, default=set()),
     ]
 
 
-def test_list():
-    test = parse(ArgTest, "-l", "one", "-l", "two")
+@backends
+def test_list(backend):
+    test = parse(ArgTest, "-l", "one", "-l", "two", backend=backend)
     assert test.list == ["one", "two"]
 
 
-def test_tuple():
-    test = parse(ArgTest, "-t", "one", "-t", "two")
+@backends
+def test_tuple(backend):
+    test = parse(ArgTest, "-t", "one", "-t", "two", backend=backend)
     assert test.tuple == ("one", "two")
 
 
-def test_set():
-    test = parse(ArgTest, "-s", "one", "-s", "two")
+@backends
+def test_set(backend):
+    test = parse(ArgTest, "-s", "one", "-s", "two", backend=backend)
     assert test.set == {"one", "two"}
 
 
-def test_invalid():
-    with pytest.raises(
-        ValueError,
-        match=r"invalid choice: 'wat' \(choose from 'one', 'two', 'three', '4'\)",
-    ):
-        parse(ArgTest, "-l", "one", "-l", "wat")
+@backends
+def test_invalid(backend):
+    with pytest.raises(cappa.Exit) as e:
+        parse(ArgTest, "-l", "one", "-l", "wat", backend=backend)
+
+    message = str(e.value.message).lower()
+    assert "invalid choice: 'wat' (choose from 'one', 'two', 'three', '4')" in message
diff --git a/tests/arg/test_literal_tagged_union.py b/tests/arg/test_literal_tagged_union.py
index 20b5f2d..0287b77 100644
--- a/tests/arg/test_literal_tagged_union.py
+++ b/tests/arg/test_literal_tagged_union.py
@@ -3,6 +3,8 @@
 from dataclasses import dataclass
 from typing import Literal
 
+# from tests.utils import parse, backends
+
 
 @dataclass
 class ArgTest:
@@ -12,16 +14,19 @@ class ArgTest:
     ]
 
 
-# def test_str():
-#     test = parse(ArgTest, "one", "string")
+# @backends
+# def test_str(backend):
+#     test = parse(ArgTest, "one", "string", backend=backend)
 #     assert test.name == ("one", "string")
 #
 #
-# def test_int():
-#     test = parse(ArgTest, "two", "4")
+# @backends
+# def test_int(backend):
+#     test = parse(ArgTest, "two", "4", backend=backend)
 #     assert test.name == ("two", 4)
 #
 #
-# def test_float():
-#     test = parse(ArgTest, "three", "1.4")
+# @backends
+# def test_float(backend):
+#     test = parse(ArgTest, "three", "1.4", backend=backend)
 #     assert test.name == ("three", 1.4)
diff --git a/tests/arg/test_mapping_failure.py b/tests/arg/test_mapping_failure.py
new file mode 100644
index 0000000..e7df3c9
--- /dev/null
+++ b/tests/arg/test_mapping_failure.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import cappa
+import pytest
+
+from tests.utils import backends, parse
+
+
+@backends
+def test_default(backend):
+    @dataclass
+    class ArgTest:
+        default: int
+
+    with pytest.raises(cappa.Exit) as e:
+        parse(ArgTest, "foo", backend=backend)
+
+    assert e.value.code == 2
+    assert (
+        e.value.message
+        == "Invalid value for 'default' with value 'foo': invalid literal for int() with base 10: 'foo'"
+    )
diff --git a/tests/arg/test_name_disallowed.py b/tests/arg/test_name_disallowed.py
index b096cd3..3dd3743 100644
--- a/tests/arg/test_name_disallowed.py
+++ b/tests/arg/test_name_disallowed.py
@@ -6,10 +6,11 @@
 import pytest
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_arg_name_disallowed():
+@backends
+def test_arg_name_disallowed(backend):
     @dataclass
     class ArgTest:
         bad: Annotated[bool, cappa.Arg(name="oops")] = False
@@ -17,4 +18,4 @@ class ArgTest:
     with pytest.raises(
         ValueError, match="Arg 'name' cannot be set when using automatic inference."
     ):
-        parse(ArgTest)
+        parse(ArgTest, backend=backend)
diff --git a/tests/arg/test_optional_positional.py b/tests/arg/test_optional_positional.py
index 6bdf299..aa8f6da 100644
--- a/tests/arg/test_optional_positional.py
+++ b/tests/arg/test_optional_positional.py
@@ -2,26 +2,28 @@
 
 from dataclasses import dataclass
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_positional_with_default_is_optional():
+@backends
+def test_positional_with_default_is_optional(backend):
     @dataclass
     class ArgTest:
         arg: int = 0
 
-    result = parse(ArgTest)
+    result = parse(ArgTest, backend=backend)
     assert result.arg == 0
 
 
-def test_multiple_positionals_fill_in_order():
+@backends
+def test_multiple_positionals_fill_in_order(backend):
     @dataclass
     class ArgTest:
         arg: int = 0
         arg2: int = 0
         arg3: int = 0
 
-    result = parse(ArgTest, "3", "5")
+    result = parse(ArgTest, "3", "5", backend=backend)
     assert result.arg == 3
     assert result.arg2 == 5
     assert result.arg3 == 0
diff --git a/tests/arg/test_parse.py b/tests/arg/test_parse.py
index bc70701..cd619be 100644
--- a/tests/arg/test_parse.py
+++ b/tests/arg/test_parse.py
@@ -4,9 +4,10 @@
 from typing import Tuple
 
 import cappa
+import pytest
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 def split(value: str):
@@ -14,19 +15,43 @@ def split(value: str):
     return int(a), int(b)
 
 
-def test_explicit_parse_function():
+def parse_int(val):
+    try:
+        return int(val)
+    except Exception:
+        raise cappa.Exit("no go amego", code=2)
+
+
+@backends
+def test_explicit_parse_function(backend):
     @dataclass
     class ArgTest:
         numbers: Annotated[int, cappa.Arg(parse=int)]
 
-    test = parse(ArgTest, "1")
+    test = parse(ArgTest, "1", backend=backend)
     assert test.numbers == 1
 
 
-def test_not_typeable_parse_function():
+@backends
+def test_not_typeable_parse_function(backend):
     @dataclass
     class ArgTest:
         numbers: Annotated[Tuple[int, int], cappa.Arg(parse=split)]
 
-    test = parse(ArgTest, "1,3")
+    test = parse(ArgTest, "1,3", backend=backend)
     assert test.numbers == (1, 3)
+
+
+@backends
+def test_parse_failure(backend):
+    """An explicit exit takes message precedence, and do not include extra cappa text."""
+
+    @dataclass
+    class ArgTest:
+        numbers: Annotated[int, cappa.Arg(parse=parse_int)]
+
+    with pytest.raises(cappa.Exit) as e:
+        parse(ArgTest, "one", backend=backend)
+
+    assert e.value.code == 2
+    assert e.value.message == "no go amego"
diff --git a/tests/arg/test_pipe_union.py b/tests/arg/test_pipe_union.py
index 8381b9a..5aeb560 100644
--- a/tests/arg/test_pipe_union.py
+++ b/tests/arg/test_pipe_union.py
@@ -6,14 +6,15 @@
 
 import pytest
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @pytest.mark.skipif(sys.version_info < (3, 10), reason="requires 3.10")
-def test_valid():
+@backends
+def test_valid(backend):
     @dataclass
     class ArgTest:
         name: Literal["one"] | Literal["two"] | Literal["three"] | Literal[4]
 
-    test = parse(ArgTest, "two")
+    test = parse(ArgTest, "two", backend=backend)
     assert test.name == "two"
diff --git a/tests/arg/test_required.py b/tests/arg/test_required.py
index d369af5..d4f393f 100644
--- a/tests/arg/test_required.py
+++ b/tests/arg/test_required.py
@@ -4,7 +4,7 @@
 import pytest
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -13,17 +13,25 @@ class Command:
     b: Annotated[str, cappa.Arg(short=True, required=True)] = "asdf"
 
 
-def test_required_implicit():
-    with pytest.raises(ValueError, match=r"are required: -a"):
-        parse(Command, "-b", "b")
+@backends
+def test_required_implicit(backend):
+    with pytest.raises(cappa.Exit) as e:
+        parse(Command, "-b", "b", backend=backend)
 
-    result = parse(Command, "-a", "a", "-b", "b")
+    assert e.value.code == 2
+    assert "are required: -a" in str(e.value.message)
+
+    result = parse(Command, "-a", "a", "-b", "b", backend=backend)
     assert result == Command(a="a", b="b")
 
 
-def test_required_explicit():
-    with pytest.raises(ValueError, match=r"are required: -b"):
-        parse(Command, "-a", "a")
+@backends
+def test_required_explicit(backend):
+    with pytest.raises(cappa.Exit) as e:
+        parse(Command, "-a", "a", backend=backend)
+
+    assert e.value.code == 2
+    assert "are required: -b" in str(e.value.message)
 
     result = parse(Command, "-a", "a", "-b", "b")
     assert result == Command(a="a", b="b")
diff --git a/tests/arg/test_set.py b/tests/arg/test_set.py
index c62101b..7336366 100644
--- a/tests/arg/test_set.py
+++ b/tests/arg/test_set.py
@@ -6,10 +6,11 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_list_option():
+@backends
+def test_list_option(backend):
     @dataclass
     class ArgTest:
         variable_number: Annotated[Set[str], cappa.Arg(short=True, long=True)] = field(
@@ -26,11 +27,13 @@ class ArgTest:
         "two",
         "-v",
         "two",
+        backend=backend,
     )
     assert test.variable_number == {"one", "two"}
 
 
-def test_list_positional():
+@backends
+def test_list_positional(backend):
     @dataclass
     class ArgTest:
         variable_number: Set[int] = field(default_factory=set)
@@ -43,5 +46,6 @@ class ArgTest:
         "2",
         "3",
         "1",
+        backend=backend,
     )
     assert test.variable_number == {1, 2, 3}
diff --git a/tests/arg/test_short_long.py b/tests/arg/test_short_long.py
index 6a825d1..41e8ead 100644
--- a/tests/arg/test_short_long.py
+++ b/tests/arg/test_short_long.py
@@ -5,46 +5,50 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_short_missing_dash():
+@backends
+def test_short_missing_dash(backend):
     @dataclass
     class ArgTest:
-        number: Annotated[int, cappa.Arg(short="nu")]
+        number: Annotated[int, cappa.Arg(short="n")]
 
-    result = parse(ArgTest, "-nu", "4")
+    result = parse(ArgTest, "-n", "4", backend=backend)
     assert result.number == 4
 
 
-def test_multiple_shorts():
+@backends
+def test_multiple_shorts(backend):
     @dataclass
     class ArgTest:
         number: Annotated[int, cappa.Arg(short=["n", "o"])]
 
-    result = parse(ArgTest, "-n", "4")
+    result = parse(ArgTest, "-n", "4", backend=backend)
     assert result.number == 4
 
-    result = parse(ArgTest, "-o", "4")
+    result = parse(ArgTest, "-o", "4", backend=backend)
     assert result.number == 4
 
 
-def test_long_missing_dash():
+@backends
+def test_long_missing_dash(backend):
     @dataclass
     class ArgTest:
         number: Annotated[int, cappa.Arg(long="nu")]
 
-    result = parse(ArgTest, "--nu", "4")
+    result = parse(ArgTest, "--nu", "4", backend=backend)
     assert result.number == 4
 
 
-def test_multiple_longs():
+@backends
+def test_multiple_longs(backend):
     @dataclass
     class ArgTest:
         number: Annotated[int, cappa.Arg(long=["so", "long"])]
 
-    result = parse(ArgTest, "--so", "4")
+    result = parse(ArgTest, "--so", "4", backend=backend)
     assert result.number == 4
 
-    result = parse(ArgTest, "--long", "4")
+    result = parse(ArgTest, "--long", "4", backend=backend)
     assert result.number == 4
diff --git a/tests/arg/test_subcommand_required.py b/tests/arg/test_subcommand_required.py
index 2857f27..6a84228 100644
--- a/tests/arg/test_subcommand_required.py
+++ b/tests/arg/test_subcommand_required.py
@@ -7,7 +7,7 @@
 import pytest
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -20,9 +20,12 @@ class A:
     ...
 
 
-def test_required_implicit():
-    with pytest.raises(ValueError, match=r"are required: {a}"):
+@backends
+def test_required_implicit(backend):
+    with pytest.raises(cappa.Exit) as e:
         parse(Command)
+    assert e.value.code == 2
+    assert "are required: {a}" in str(e.value.message)
 
-    result = parse(Command, "a")
+    result = parse(Command, "a", backend=backend)
     assert result == Command(subcmd=A())
diff --git a/tests/arg/test_tuple.py b/tests/arg/test_tuple.py
index a439637..4964d84 100644
--- a/tests/arg/test_tuple.py
+++ b/tests/arg/test_tuple.py
@@ -3,7 +3,7 @@
 from dataclasses import dataclass
 from typing import Tuple
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -11,6 +11,7 @@ class ArgTest:
     numbers: Tuple[int, str, float]
 
 
-def test_valid():
-    test = parse(ArgTest, "1", "2", "3.4")
+@backends
+def test_valid(backend):
+    test = parse(ArgTest, "1", "2", "3.4", backend=backend)
     assert test.numbers == (1, "2", 3.4)
diff --git a/tests/arg/test_tuple_unbounded.py b/tests/arg/test_tuple_unbounded.py
index 16b58b7..8457fab 100644
--- a/tests/arg/test_tuple_unbounded.py
+++ b/tests/arg/test_tuple_unbounded.py
@@ -6,22 +6,24 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_positional_arg():
+@backends
+def test_positional_arg(backend):
     @dataclass
     class ArgTest:
         numbers: Tuple[int, ...]
 
-    test = parse(ArgTest, "1", "2", "3", "4")
+    test = parse(ArgTest, "1", "2", "3", "4", backend=backend)
     assert test.numbers == (1, 2, 3, 4)
 
 
-def test_option_flag():
+@backends
+def test_option_flag(backend):
     @dataclass
     class ArgTest:
         numbers: Annotated[Tuple[int, ...], cappa.Arg(short=True)]
 
-    test = parse(ArgTest, "-n", "1", "-n", "2", "-n", "3", "-n", "4")
+    test = parse(ArgTest, "-n", "1", "-n", "2", "-n", "3", "-n", "4", backend=backend)
     assert test.numbers == (1, 2, 3, 4)
diff --git a/tests/command/test_help.py b/tests/command/test_help.py
index cb7bfa2..99069bf 100644
--- a/tests/command/test_help.py
+++ b/tests/command/test_help.py
@@ -4,10 +4,12 @@
 
 import cappa
 import pytest
+from cappa.output import Exit
 
 from tests.utils import parse
 
 
+@pytest.mark.help
 def test_default_help(capsys):
     @dataclass
     class Command:
@@ -18,13 +20,14 @@ class Command:
 
         ...
 
-    with pytest.raises(ValueError):
+    with pytest.raises(Exit):
         parse(Command, "--help")
 
     stdout = capsys.readouterr().out
     assert "\nSome help. More detail.\n" in stdout
 
 
+@pytest.mark.help
 def test_default_help_no_long_description(capsys):
     @dataclass
     class Command:
@@ -32,33 +35,35 @@ class Command:
 
         ...
 
-    with pytest.raises(ValueError):
+    with pytest.raises(Exit):
         parse(Command, "--help")
 
     stdout = capsys.readouterr().out
     assert "\nSome help.\n" in stdout
 
 
+@pytest.mark.help
 def test_unannotated_argument(capsys):
     @cappa.command(help="All the help.")
     @dataclass
     class Command:
         ...
 
-    with pytest.raises(ValueError):
+    with pytest.raises(Exit):
         parse(Command, "--help")
 
     stdout = capsys.readouterr().out
     assert "\nAll the help.\n" in stdout
 
 
+@pytest.mark.help
 def test_description_without_help(capsys):
     @cappa.command(description="All the help.")
     @dataclass
     class Command:
         pass
 
-    with pytest.raises(ValueError):
+    with pytest.raises(Exit):
         parse(Command, "--help")
 
     stdout = capsys.readouterr().out
diff --git a/tests/completion/__init__.py b/tests/completion/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/completion/test_argument.py b/tests/completion/test_argument.py
new file mode 100644
index 0000000..f29cab7
--- /dev/null
+++ b/tests/completion/test_argument.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from tests.utils import parse_completion
+
+
+def test_arg():
+    @dataclass
+    class Args:
+        value: str
+
+    result = parse_completion(Args, "")
+    assert not result
+
+
+def test_arg_partial():
+    @dataclass
+    class Args:
+        value: str
+
+    result = parse_completion(Args, "m")
+    assert result
+    assert result == "file"
diff --git a/tests/completion/test_base.py b/tests/completion/test_base.py
new file mode 100644
index 0000000..9bff2bd
--- /dev/null
+++ b/tests/completion/test_base.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from unittest.mock import patch
+
+import cappa
+import pytest
+from cappa.parser import backend
+
+from tests.utils import parse
+
+
+def test_unknown_shell():
+    @dataclass
+    class Args:
+        value: str
+
+    with patch("os.environ", new={"SHELL": "notbash"}):
+        with pytest.raises(cappa.Exit) as e:
+            parse(Args, "--completion", "complete", backend=backend)
+
+    assert e.value.code == 1
+    assert e.value.message == "Unknown shell"
+
+
+def test_generate_completions():
+    @dataclass
+    class Args:
+        value: str
+
+    with pytest.raises(cappa.Exit) as e:
+        parse(Args, "--completion", "generate", backend=backend)
+
+    assert e.value.code == 0
+    assert e.value.message
+    assert "_testpy_completion" in str(e.value.message)
+
+
+def test_invalid_setup():
+    @dataclass
+    class Args:
+        value: str
+
+    with pytest.raises(cappa.Exit) as e:
+        parse(Args, "--completion", "complete", backend=backend)
+
+    assert e.value.code == 0
+    assert not e.value.message
+
+
+def test_no_completion():
+    @dataclass
+    class Example:
+        ...
+
+    result = cappa.parse(Example, argv=[], backend=backend, completion=False)
+    assert result == Example()
+
+    with pytest.raises(cappa.Exit) as e:
+        cappa.parse(
+            Example,
+            argv=["ex", "--completion", "generate"],
+            backend=backend,
+            completion=False,
+        )
+    assert e.value.code == 2
+
+
+def test_arg_completion(capsys):
+    @dataclass
+    class Example:
+        ...
+
+    completion: cappa.Arg = cappa.Arg(short="-p", long="--pompletion")
+
+    result = cappa.parse(Example, argv=[], completion=completion, backend=backend)
+    assert result == Example()
+
+    with pytest.raises(SystemExit) as e:
+        cappa.parse(
+            Example,
+            argv=["ex", "-p", "generate"],
+            completion=completion,
+            backend=backend,
+        )
+    assert e.value.code == 0
+
+    out = capsys.readouterr().out
+    assert "_ex_completion" in out
+
+    with pytest.raises(SystemExit) as e:
+        cappa.parse(
+            Example,
+            argv=["ex", "--help"],
+            completion=completion,
+            backend=backend,
+        )
+    assert e.value.code == 0
+
+    out = capsys.readouterr().out
+    assert "-p, --pompletion" in out
diff --git a/tests/completion/test_choices.py b/tests/completion/test_choices.py
new file mode 100644
index 0000000..491d8ab
--- /dev/null
+++ b/tests/completion/test_choices.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal, Union
+
+import cappa
+from typing_extensions import Annotated
+
+from tests.utils import parse_completion
+
+
+def test_choices_without_help():
+    @dataclass
+    class Args:
+        value: Union[Literal["one"], Literal["two"], Literal["three"]]
+
+    result = parse_completion(Args, "")
+    assert result
+    assert result == "one:\ntwo:\nthree:"
+
+
+def test_choices_with_help():
+    @dataclass
+    class Args:
+        value: Annotated[
+            Union[Literal["one"], Literal["two"], Literal["three"]],
+            cappa.Arg(help="helpful"),
+        ]
+
+    result = parse_completion(Args, "")
+    assert result
+    assert result == "one:helpful\ntwo:helpful\nthree:helpful"
+
+
+def test_half_string():
+    @dataclass
+    class Args:
+        value: Union[Literal["one"], Literal["two"], Literal["three"]]
+
+    result = parse_completion(Args, "'on")
+    assert result
+    assert result == "one:"
diff --git a/tests/completion/test_option.py b/tests/completion/test_option.py
new file mode 100644
index 0000000..3947d34
--- /dev/null
+++ b/tests/completion/test_option.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import cappa
+from typing_extensions import Annotated
+
+from tests.utils import parse_completion
+
+
+def test_long_option_name():
+    @dataclass
+    class Args:
+        default: bool = False
+
+    result = parse_completion(Args, "--d")
+    assert result == "--default:"
+
+
+def test_long_option_name_with_help():
+    @dataclass
+    class Args:
+        default: Annotated[bool, cappa.Arg(help="Enables default")] = False
+
+    result = parse_completion(Args, "--d")
+    assert result == "--default:Enables default"
+
+
+def test_mulitple_matches():
+    @dataclass
+    class Args:
+        apple: Annotated[str, cappa.Arg(short=True, help="apple")]
+        banana: Annotated[str, cappa.Arg(short=True, help="banana")]
+
+    result = parse_completion(Args, "-")
+    assert result
+    assert "-a:apple\n-b:banana" in result
+
+
+def test_short_name():
+    @dataclass
+    class Args:
+        apple: Annotated[str, cappa.Arg(short=True)]
+
+    result = parse_completion(Args, "-a")
+    assert result is None
diff --git a/tests/completion/test_subcommand.py b/tests/completion/test_subcommand.py
new file mode 100644
index 0000000..02d6517
--- /dev/null
+++ b/tests/completion/test_subcommand.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Union
+
+import cappa
+from typing_extensions import Annotated
+
+from tests.utils import parse_completion
+
+
+@dataclass
+class Foo:
+    ...
+
+
+@dataclass
+class Bar:
+    nested_opt: Annotated[str, cappa.Arg(long=True)]
+
+
+@dataclass
+class Args:
+    value: cappa.Subcommands[Union[Foo, Bar]]
+
+
+def test_subcommand_name_no_partial():
+    result = parse_completion(Args, "")
+    assert result == "foo:\nbar:"
+
+
+def test_subcommand_name_with_partial():
+    result = parse_completion(Args, "f")
+    assert result
+    assert result == "foo:"
+
+
+def test_subcommand_args():
+    result = parse_completion(Args, "bar", "--n")
+    assert result
+    assert result == "--nested-opt:"
diff --git a/tests/completion/test_successful_parse.py b/tests/completion/test_successful_parse.py
new file mode 100644
index 0000000..00dbffd
--- /dev/null
+++ b/tests/completion/test_successful_parse.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from tests.utils import parse_completion
+
+
+def test_long_option_name():
+    @dataclass
+    class Args:
+        default: bool = False
+
+    result = parse_completion(Args, "--default")
+    assert result is None
diff --git a/tests/help/__init__.py b/tests/help/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/help/test_group.py b/tests/help/test_group.py
new file mode 100644
index 0000000..6280b50
--- /dev/null
+++ b/tests/help/test_group.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+import cappa
+import pytest
+from typing_extensions import Annotated
+
+from tests.utils import backends, parse
+
+
+@backends
+def test_string_group(backend, capsys):
+    @dataclass
+    class Args:
+        name: Annotated[str, cappa.Arg(group="Strings")]
+
+    with pytest.raises(cappa.HelpExit) as e:
+        parse(Args, "--help", backend=backend)
+
+    assert e.value.code == 0
+
+    out = capsys.readouterr().out
+    assert re.match(r".*Strings:?\s*\n\s*name.*", out, re.DOTALL)
diff --git a/tests/invoke/test_bad_handler.py b/tests/invoke/test_bad_handler.py
index d3891b8..8dd2bcb 100644
--- a/tests/invoke/test_bad_handler.py
+++ b/tests/invoke/test_bad_handler.py
@@ -5,17 +5,18 @@
 import cappa
 import pytest
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 
-def test_missing_invoke():
+@backends
+def test_missing_invoke(backend):
     @cappa.command()
     @dataclass
     class Command:
         ...
 
     with pytest.raises(RuntimeError) as e:
-        invoke(Command)
+        invoke(Command, backend=backend)
 
     exc = e.value
     cause = exc.__cause__
@@ -26,14 +27,15 @@ class Command:
     )
 
 
-def test_string_missing_function():
+@backends
+def test_string_missing_function(backend):
     @cappa.command(invoke="sys")
     @dataclass
     class Command:
         ...
 
     with pytest.raises(RuntimeError) as e:
-        invoke(Command)
+        invoke(Command, backend=backend)
 
     exc = e.value
     cause = exc.__cause__
@@ -44,14 +46,15 @@ class Command:
     )
 
 
-def test_string_references_invalid_module():
+@backends
+def test_string_references_invalid_module(backend):
     @cappa.command(invoke="completely.wrong")
     @dataclass
     class Command:
         ...
 
     with pytest.raises(RuntimeError) as e:
-        invoke(Command)
+        invoke(Command, backend=backend)
 
     exc = e.value
     cause = exc.__cause__
@@ -61,14 +64,15 @@ class Command:
     )
 
 
-def test_string_references_invalid_function():
+@backends
+def test_string_references_invalid_function(backend):
     @cappa.command(invoke="builtins.meow")
     @dataclass
     class Command:
         ...
 
     with pytest.raises(RuntimeError) as e:
-        invoke(Command)
+        invoke(Command, backend=backend)
 
     exc = e.value
     cause = exc.__cause__
@@ -79,14 +83,15 @@ class Command:
     )
 
 
-def test_string_reference_not_callable():
+@backends
+def test_string_reference_not_callable(backend):
     @cappa.command(invoke="builtins.__name__")
     @dataclass
     class Command:
         ...
 
     with pytest.raises(RuntimeError) as e:
-        invoke(Command)
+        invoke(Command, backend=backend)
 
     exc = e.value
     cause = exc.__cause__
diff --git a/tests/invoke/test_callable_dataclass.py b/tests/invoke/test_callable_dataclass.py
index b9127b1..8115a15 100644
--- a/tests/invoke/test_callable_dataclass.py
+++ b/tests/invoke/test_callable_dataclass.py
@@ -5,7 +5,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 
 def dependency():
@@ -20,6 +20,7 @@ def __call__(self, dep: Annotated[int, cappa.Dep(dependency)]):
         return self.foo + dep
 
 
-def test_invoke_top_level_command():
-    result = invoke(Command, "7")
+@backends
+def test_invoke_top_level_command(backend):
+    result = invoke(Command, "7", backend=backend)
     assert result == 7 + 5
diff --git a/tests/invoke/test_default_arg_non_dep.py b/tests/invoke/test_default_arg_non_dep.py
index 17414fd..e9860ae 100644
--- a/tests/invoke/test_default_arg_non_dep.py
+++ b/tests/invoke/test_default_arg_non_dep.py
@@ -5,7 +5,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 
 def has_default(default: bool = True):
@@ -24,6 +24,7 @@ class Command:
     ...
 
 
-def test_invoke_top_level_command():
-    result = invoke(Command)
+@backends
+def test_invoke_top_level_command(backend):
+    result = invoke(Command, backend=backend)
     assert result == {"one": True, "default": True}
diff --git a/tests/invoke/test_explicit_deps.py b/tests/invoke/test_explicit_deps.py
index 8954034..2b128b1 100644
--- a/tests/invoke/test_explicit_deps.py
+++ b/tests/invoke/test_explicit_deps.py
@@ -5,7 +5,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 
 def level_three():
@@ -30,6 +30,7 @@ class Command:
     ...
 
 
-def test_invoke_top_level_command():
-    result = invoke(Command)
+@backends
+def test_invoke_top_level_command(backend):
+    result = invoke(Command, backend=backend)
     assert result == {"level_one": {"level_two": {"level_three": True}}}
diff --git a/tests/invoke/test_explicit_deps_multiple_paths.py b/tests/invoke/test_explicit_deps_multiple_paths.py
index 03f1d9a..e72ce7d 100644
--- a/tests/invoke/test_explicit_deps_multiple_paths.py
+++ b/tests/invoke/test_explicit_deps_multiple_paths.py
@@ -6,7 +6,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 log = logging.getLogger("test")
 
@@ -43,14 +43,15 @@ class Command:
     ...
 
 
-def test_invoke_top_level_command(caplog):
+@backends
+def test_invoke_top_level_command(caplog, backend):
     """Assert multiple paths to explicit dependencies are still fullfilled.
 
     * Ensure they are only called once overall, regardless of the number of downstream dependents.
     """
     caplog.set_level(logging.DEBUG)
 
-    result = invoke(Command)
+    result = invoke(Command, backend=backend)
 
     assert result == {
         "one": {"two": {"three": True}, "three": True},
diff --git a/tests/invoke/test_global_dep.py b/tests/invoke/test_global_dep.py
index 4a6c154..0178db4 100644
--- a/tests/invoke/test_global_dep.py
+++ b/tests/invoke/test_global_dep.py
@@ -4,7 +4,7 @@
 
 import cappa
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 
 def foo(command: Command):
@@ -25,8 +25,9 @@ class Command:
     ...
 
 
-def test_invoke_top_level_command(capsys):
-    result = invoke(Command, deps=[foo, bar])
+@backends
+def test_invoke_top_level_command(capsys, backend):
+    result = invoke(Command, deps=[foo, bar], backend=backend)
     assert result == 1
 
     out = capsys.readouterr().out
diff --git a/tests/invoke/test_implicit_deps.py b/tests/invoke/test_implicit_deps.py
index 98f0527..d24e7f7 100644
--- a/tests/invoke/test_implicit_deps.py
+++ b/tests/invoke/test_implicit_deps.py
@@ -6,7 +6,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 
 def top_level_command(tlc: TopLevelCommand):
@@ -30,11 +30,13 @@ class TopLevelCommand:
     subcommand: Annotated[Union[Subcommand, None], cappa.Subcommand] = None
 
 
-def test_invoke_top_level_command():
-    result = invoke(TopLevelCommand, "--foo", "4")
+@backends
+def test_invoke_top_level_command(backend):
+    result = invoke(TopLevelCommand, "--foo", "4", backend=backend)
     assert result == ("tlc", TopLevelCommand(foo=4))
 
 
-def test_invoke_subcommand():
-    result = invoke(TopLevelCommand, "sub", "4")
+@backends
+def test_invoke_subcommand(backend):
+    result = invoke(TopLevelCommand, "sub", "4", backend=backend)
     assert result == ("sub", Subcommand(bar=4))
diff --git a/tests/invoke/test_invalid_depdendency.py b/tests/invoke/test_invalid_depdendency.py
index 3cf43bd..f1b448a 100644
--- a/tests/invoke/test_invalid_depdendency.py
+++ b/tests/invoke/test_invalid_depdendency.py
@@ -5,10 +5,11 @@
 import cappa
 import pytest
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 
-def test_invalid_dependency():
+@backends
+def test_invalid_dependency(backend):
     def command(levels: int):
         return levels
 
@@ -18,7 +19,7 @@ class Command:
         ...
 
     with pytest.raises(RuntimeError) as e:
-        invoke(Command)
+        invoke(Command, backend=backend)
 
     exc = e.value
     cause = exc.__cause__
@@ -26,7 +27,8 @@ class Command:
     assert "`levels: int` is not a valid dependency for Dep(command)." == str(cause)
 
 
-def test_unannotated_argument():
+@backends
+def test_unannotated_argument(backend):
     def command(levels):
         return levels
 
@@ -36,7 +38,7 @@ class Command:
         ...
 
     with pytest.raises(RuntimeError) as e:
-        invoke(Command)
+        invoke(Command, backend=backend)
 
     exc = e.value
     cause = exc.__cause__
diff --git a/tests/invoke/test_nested_implicit_dep.py b/tests/invoke/test_nested_implicit_dep.py
index fc278bf..15f7e6f 100644
--- a/tests/invoke/test_nested_implicit_dep.py
+++ b/tests/invoke/test_nested_implicit_dep.py
@@ -6,7 +6,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 
 def foo(tlc: TopLevelCommand, subcommand: Subcommand, foo: Foo):
@@ -30,8 +30,9 @@ class TopLevelCommand:
     cmd: Annotated[Union[Subcommand, None], cappa.Subcommand] = None
 
 
-def test_every_dependency_level():
-    result = invoke(TopLevelCommand, "subcommand", "foo")
+@backends
+def test_every_dependency_level(backend):
+    result = invoke(TopLevelCommand, "subcommand", "foo", backend=backend)
     assert result == (
         TopLevelCommand(cmd=Subcommand(cmd=Foo())),
         Subcommand(cmd=Foo()),
@@ -39,6 +40,7 @@ def test_every_dependency_level():
     )
 
 
-def test_no_subcmd():
-    result = invoke(TopLevelCommand)
+@backends
+def test_no_subcmd(backend):
+    result = invoke(TopLevelCommand, backend=backend)
     assert result == 4
diff --git a/tests/invoke/test_output.py b/tests/invoke/test_output.py
new file mode 100644
index 0000000..dc9e4f5
--- /dev/null
+++ b/tests/invoke/test_output.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import cappa
+
+from tests.utils import backends, invoke
+
+
+@dataclass
+class TopLevelCommand:
+    def __call__(self, out: cappa.Output):
+        out.output("woah!")
+        out.error("woops!")
+
+
+@backends
+def test_outputs_output(backend, capsys):
+    invoke(TopLevelCommand, backend=backend)
+
+    outerr = capsys.readouterr()
+    assert outerr.out == "woah!\n"
+    assert outerr.err == "woops!\n"
diff --git a/tests/invoke/test_stubbing_deps.py b/tests/invoke/test_stubbing_deps.py
index d0f9f44..221c06b 100644
--- a/tests/invoke/test_stubbing_deps.py
+++ b/tests/invoke/test_stubbing_deps.py
@@ -5,7 +5,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import invoke
+from tests.utils import backends, invoke
 
 
 def two():
@@ -30,9 +30,10 @@ class Command:
     ...
 
 
-def test_invoke_top_level_command():
+@backends
+def test_invoke_top_level_command(backend):
     def stub_two():
         return 4
 
-    result = invoke(Command, deps={two: cappa.Dep(stub_two), three: 6})
+    result = invoke(Command, deps={two: cappa.Dep(stub_two), three: 6}, backend=backend)
     assert result == (4, 6)
diff --git a/tests/parser/__init__.py b/tests/parser/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/parser/test_bad_choice_assistance.py b/tests/parser/test_bad_choice_assistance.py
new file mode 100644
index 0000000..284c734
--- /dev/null
+++ b/tests/parser/test_bad_choice_assistance.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import cappa
+import pytest
+from cappa.parser import backend
+from typing_extensions import Annotated
+
+from tests.utils import backends, parse
+
+
+@backends
+def test_invalid_choice_help(backend):
+    @dataclass
+    class Args:
+        value: Annotated[str, cappa.Arg(long=True)]
+
+    with pytest.raises(cappa.Exit) as e:
+        parse(Args, "--value", "ok", "--meow", "wat", backend=backend)
+    assert e.value.code == 2
+    assert "unrecognized arguments: --meow" in str(e.value.message).lower()
+
+
+def test_invalid_choice_help_possible_options():
+    @dataclass
+    class Args:
+        value: Annotated[str, cappa.Arg(long=True)]
+
+    with pytest.raises(cappa.Exit) as e:
+        parse(Args, "--val", "on", backend=backend)
+    assert e.value.code == 2
+    assert "Unrecognized arguments: --val (Did you mean: --value)" in str(
+        e.value.message
+    )
diff --git a/tests/parser/test_double_dash.py b/tests/parser/test_double_dash.py
new file mode 100644
index 0000000..4043b28
--- /dev/null
+++ b/tests/parser/test_double_dash.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import List, Union
+
+import cappa
+from typing_extensions import Annotated
+
+from tests.utils import backends, parse
+
+
+@backends
+def test_collect_post_dash_args(backend):
+    @dataclass
+    class Args:
+        foo: str
+        raw: Annotated[Union[List[str], None], cappa.Arg(num_args=-1)] = None
+
+    test = parse(Args, "foovalue", "--", "--raw", "value", backend=backend)
+    assert test.foo == "foovalue"
+    assert test.raw == ["--raw", "value"]
diff --git a/tests/parser/test_explicit_equal_option.py b/tests/parser/test_explicit_equal_option.py
new file mode 100644
index 0000000..c9a5080
--- /dev/null
+++ b/tests/parser/test_explicit_equal_option.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import cappa
+from typing_extensions import Annotated
+
+from tests.utils import backends, parse
+
+
+@backends
+def test_unrecognized_post_dash_arg(backend):
+    @dataclass
+    class Args:
+        value: Annotated[str, cappa.Arg(long=True)]
+
+    result = parse(Args, "--value=val", backend=backend)
+    assert result == Args(value="val")
diff --git a/tests/parser/test_interspersed_options.py b/tests/parser/test_interspersed_options.py
new file mode 100644
index 0000000..23a2285
--- /dev/null
+++ b/tests/parser/test_interspersed_options.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import cappa
+from typing_extensions import Annotated
+
+from tests.utils import backends, parse
+
+
+@backends
+def test_invalid_choice_help(backend):
+    @dataclass
+    class Args:
+        arg: str
+        option: Annotated[str, cappa.Arg(long=True)]
+        arg2: str
+        option2: Annotated[str, cappa.Arg(long=True)]
+
+    result = parse(
+        Args, "arg", "--option=opt", "arg2", "--option2=opt2", backend=backend
+    )
+    assert result == Args(arg="arg", option="opt", arg2="arg2", option2="opt2")
diff --git a/tests/parser/test_missing_num_args.py b/tests/parser/test_missing_num_args.py
new file mode 100644
index 0000000..9e67ffd
--- /dev/null
+++ b/tests/parser/test_missing_num_args.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Tuple
+
+import cappa
+import pytest
+from typing_extensions import Annotated
+
+from tests.utils import backends, parse
+
+
+@backends
+def test_invalid_choice_help(backend):
+    @dataclass
+    class Args:
+        arg: Tuple[str, str]
+        option: Annotated[str, cappa.Arg(long=True)]
+
+    with pytest.raises(cappa.Exit) as e:
+        parse(Args, "arg", "--option=opt", backend=backend)
+    assert e.value.code == 2
+
+    message = str(e.value.message)
+    if backend:
+        assert message == "Argument requires 2 values, only found 1 ('arg' so far)."
+    else:
+        assert "the following arguments are required: arg" in message
diff --git a/tests/parser/test_unregonized_argument.py b/tests/parser/test_unregonized_argument.py
new file mode 100644
index 0000000..3667c80
--- /dev/null
+++ b/tests/parser/test_unregonized_argument.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Union
+
+import cappa
+import pytest
+
+from tests.utils import backends, parse
+
+
+@backends
+def test_unrecognized_post_dash_arg(backend):
+    @dataclass
+    class Args:
+        foo: str
+        raw: Union[str, None] = None
+
+    with pytest.raises(cappa.Exit) as e:
+        parse(Args, "foovalue", "raw", "raw2", backend=backend)
+
+    assert e.value.code == 2
+    assert "unrecognized arguments: raw2" in str(e.value.message).lower()
diff --git a/tests/subcommand/test_base.py b/tests/subcommand/test_base.py
index 1909ca9..e053ec3 100644
--- a/tests/subcommand/test_base.py
+++ b/tests/subcommand/test_base.py
@@ -7,7 +7,7 @@
 import pytest
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -20,11 +20,15 @@ class RequiredMissing:
     subcommand: Annotated[RequiredMissingOne, cappa.Subcommand]
 
 
-def test_required_missing():
-    with pytest.raises(
-        Exception, match=r"following arguments are required: {required-missing-one}"
-    ):
-        parse(RequiredMissing)
+@backends
+def test_required_missing(backend):
+    with pytest.raises(cappa.Exit) as e:
+        parse(RequiredMissing, backend=backend)
+    assert isinstance(e.value.message, str)
+    assert (
+        "the following arguments are required: {required-missing-one}"
+        in e.value.message.lower()
+    )
 
 
 @dataclass
@@ -44,16 +48,21 @@ class RequiredProvided:
     ]
 
 
-def test_required_provided():
-    test = parse(RequiredProvided, "required-provided-one", "--foo", "foo")
+@backends
+def test_required_provided(backend):
+    test = parse(
+        RequiredProvided, "required-provided-one", "--foo", "foo", backend=backend
+    )
     assert isinstance(test.subcommand, RequiredProvidedOne)
     assert test.subcommand.foo == "foo"
 
-    test = parse(RequiredProvided, "required-provided-two")
+    test = parse(RequiredProvided, "required-provided-two", backend=backend)
     assert isinstance(test.subcommand, RequiredProvidedTwo)
     assert test.subcommand.bar is None
 
-    test = parse(RequiredProvided, "required-provided-two", "--bar", "bar")
+    test = parse(
+        RequiredProvided, "required-provided-two", "--bar", "bar", backend=backend
+    )
     assert isinstance(test.subcommand, RequiredProvidedTwo)
     assert test.subcommand.bar == "bar"
 
@@ -69,6 +78,7 @@ class NamedSubcommand:
     subcommand: Annotated[NamedSubcommandOne, cappa.Subcommand()]
 
 
-def test_named_subcommand():
-    test = parse(NamedSubcommand, "one")
+@backends
+def test_named_subcommand(backend):
+    test = parse(NamedSubcommand, "one", backend=backend)
     assert isinstance(test.subcommand, NamedSubcommandOne)
diff --git a/tests/subcommand/test_group.py b/tests/subcommand/test_group.py
new file mode 100644
index 0000000..db45990
--- /dev/null
+++ b/tests/subcommand/test_group.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+import cappa
+import pytest
+from typing_extensions import Annotated
+
+from tests.utils import backends, parse
+
+
+@dataclass
+class Foo:
+    ...
+
+
+@dataclass
+class Args:
+    subcommand: Annotated[Foo, cappa.Subcommand(group="Yup")]
+
+
+@backends
+def test_required_missing(backend, capsys):
+    with pytest.raises(cappa.Exit) as e:
+        parse(Args, "--help", backend=backend)
+    assert e.value.code == 0
+
+    help = capsys.readouterr().out
+    assert re.match(r".*Yup:?[\n\s]+\{foo\}.*", help, re.DOTALL)
diff --git a/tests/subcommand/test_subcommand_type.py b/tests/subcommand/test_subcommand_type.py
index 9c18173..51e8932 100644
--- a/tests/subcommand/test_subcommand_type.py
+++ b/tests/subcommand/test_subcommand_type.py
@@ -5,12 +5,12 @@
 
 import cappa
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
 class Command:
-    cmd: cappa.Subcmd[typing.Union[Foo, Bar]]
+    cmd: cappa.Subcommands[typing.Union[Foo, Bar]]
 
 
 @dataclass
@@ -23,9 +23,10 @@ class Bar:
     b: int
 
 
-def test_subcommand_type_type_alias():
-    result = parse(Command, "foo", "4")
+@backends
+def test_subcommand_type_type_alias(backend):
+    result = parse(Command, "foo", "4", backend=backend)
     assert result == Command(cmd=Foo(f=4))
 
-    result = parse(Command, "bar", "4")
+    result = parse(Command, "bar", "4", backend=backend)
     assert result == Command(cmd=Bar(b=4))
diff --git a/tests/test_attrs.py b/tests/test_attrs.py
index 962263a..99ab5a8 100644
--- a/tests/test_attrs.py
+++ b/tests/test_attrs.py
@@ -7,7 +7,7 @@
 import cappa
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 factory = attr.Factory(lambda: [4])
 
@@ -21,6 +21,7 @@ class Command:
     )
 
 
-def test_attrs():
-    result = parse(Command, "meow")
+@backends
+def test_attrs(backend):
+    result = parse(Command, "meow", backend=backend)
     assert result == Command(name="meow", default=4, default_factory=[4])
diff --git a/tests/test_base.py b/tests/test_base.py
index 7f84864..8d9765d 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -3,8 +3,11 @@
 import cappa
 import pytest
 
+from tests.utils import backends
 
-def test_color_off():
+
+@backends
+def test_color_off(backend):
     def no_op():
         pass
 
@@ -13,81 +16,99 @@ def no_op():
     class Example:
         ...
 
-    cappa.invoke(Example, color=False, argv=[])
+    cappa.invoke(Example, color=False, argv=[], backend=backend)
 
 
-def test_no_help():
+@backends
+def test_no_help(backend):
     @dataclass
     class Example:
         ...
 
-    result = cappa.parse(Example, argv=[], help=False)
+    result = cappa.parse(Example, argv=[], help=False, backend=backend)
     assert result == Example()
 
-    with pytest.raises(SystemExit):
-        cappa.parse(Example, argv=["ex", "-h"], help=False)
+    with pytest.raises(SystemExit) as e:
+        cappa.parse(Example, argv=["ex", "-h"], help=False, backend=backend)
+    assert e.value.code == 2
 
-    with pytest.raises(SystemExit):
-        cappa.parse(Example, argv=["ex", "--help"], help=False)
+    with pytest.raises(SystemExit) as e:
+        cappa.parse(Example, argv=["ex", "--help"], help=False, backend=backend)
+    assert e.value.code == 2
 
 
-def test_arg_help(capsys):
+@pytest.mark.help
+@backends
+def test_arg_help(capsys, backend):
     @dataclass
     class Example:
         ...
 
     help: cappa.Arg = cappa.Arg(short="-p", long="--pelp")
 
-    result = cappa.parse(Example, argv=[], help=help)
+    result = cappa.parse(Example, argv=[], help=help, backend=backend)
     assert result == Example()
 
-    with pytest.raises(SystemExit):
-        cappa.parse(Example, argv=["ex", "-p"], help=help)
+    with pytest.raises(SystemExit) as e:
+        cappa.parse(Example, argv=["ex", "-p"], help=help, backend=backend)
+    assert e.value.code == 0
+
     out = capsys.readouterr().out
     assert "-p, --pelp" in out
 
-    with pytest.raises(SystemExit):
-        cappa.parse(Example, argv=["ex", "--pelp"], help=help)
+    with pytest.raises(SystemExit) as e:
+        cappa.parse(Example, argv=["ex", "--pelp"], help=help, backend=backend)
+    assert e.value.code == 0
+
     out = capsys.readouterr().out
     assert "-p, --pelp" in out
 
 
-def test_version_enabled(capsys):
+@backends
+def test_version_enabled(capsys, backend):
     @dataclass
     class Example:
         ...
 
-    with pytest.raises(SystemExit):
-        cappa.parse(Example, argv=["ex", "--version"], version="1.2.3")
+    with pytest.raises(SystemExit) as e:
+        cappa.parse(Example, argv=["ex", "--version"], version="1.2.3", backend=backend)
+    assert e.value.code == 0
+
     out = capsys.readouterr().out
     assert "1.2.3" in out
 
 
-def test_arg_version(capsys):
+@backends
+def test_arg_version(capsys, backend):
     @dataclass
     class Example:
         ...
 
     version: cappa.Arg = cappa.Arg("1.2.3", short="-p", long="--persion")
 
-    result = cappa.parse(Example, argv=[], version=version)
+    result = cappa.parse(Example, argv=[], version=version, backend=backend)
     assert result == Example()
 
-    with pytest.raises(SystemExit):
-        cappa.parse(Example, argv=["ex", "-p"], version=version)
+    with pytest.raises(SystemExit) as e:
+        cappa.parse(Example, argv=["ex", "-p"], version=version, backend=backend)
+    assert e.value.code == 0
+
     out = capsys.readouterr().out
     assert "1.2.3" in out
 
-    with pytest.raises(SystemExit):
-        cappa.parse(Example, argv=["ex", "--persion"], version=version)
+    with pytest.raises(SystemExit) as e:
+        cappa.parse(Example, argv=["ex", "--persion"], version=version, backend=backend)
+    assert e.value.code == 0
+
     out = capsys.readouterr().out
     assert "1.2.3" in out
 
 
-def test_version_without_help(capsys):
+@backends
+def test_version_without_help(backend):
     @dataclass
     class Example:
         ...
 
-    result = cappa.parse(Example, argv=[], version="1.2.3", help=False)
+    result = cappa.parse(Example, argv=[], version="1.2.3", help=False, backend=backend)
     assert result == Example()
diff --git a/tests/test_docstring.py b/tests/test_docstring.py
index 6365e7e..0502812 100644
--- a/tests/test_docstring.py
+++ b/tests/test_docstring.py
@@ -21,8 +21,9 @@ class IncludesDocstring:
     bar: bool = False
 
 
+@pytest.mark.help
 def test_required_provided(capsys):
-    with pytest.raises(ValueError):
+    with pytest.raises(cappa.Exit):
         parse(IncludesDocstring, "--help")
 
     result = capsys.readouterr().out
@@ -33,12 +34,13 @@ def test_required_provided(capsys):
     assert "--bar       whether to bar (default: False)" in result
 
 
+@pytest.mark.help
 def test_just_a_title(capsys):
     @dataclass
     class IncludesDocstring:
         """Just a title."""
 
-    with pytest.raises(ValueError):
+    with pytest.raises(cappa.Exit):
         parse(IncludesDocstring, "--help")
 
     result = capsys.readouterr().out
@@ -46,13 +48,14 @@ class IncludesDocstring:
     assert "Just a title" in result
 
 
+@pytest.mark.help
 def test_docstring_with_explicit_help(capsys):
     @cappa.command(help="help text")
     @dataclass
     class IncludesDocstring:
         """Just a title."""
 
-    with pytest.raises(ValueError):
+    with pytest.raises(cappa.Exit):
         parse(IncludesDocstring, "--help")
 
     result = capsys.readouterr().out
@@ -61,13 +64,14 @@ class IncludesDocstring:
     assert "help text" in result
 
 
+@pytest.mark.help
 def test_docstring_with_explicit_description(capsys):
     @cappa.command(description="description")
     @dataclass
     class IncludesDocstring:
         """Just a title."""
 
-    with pytest.raises(ValueError):
+    with pytest.raises(cappa.Exit):
         parse(IncludesDocstring, "--help")
 
     result = capsys.readouterr().out
diff --git a/tests/test_example.py b/tests/test_example.py
index 78407fd..4dbcc86 100644
--- a/tests/test_example.py
+++ b/tests/test_example.py
@@ -6,37 +6,41 @@
 
 import cappa
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
-def test_positional_string():
+@backends
+def test_positional_string(backend):
     @dataclass
     class ArgTest:
         name: str
 
-    test = parse(ArgTest, "thename")
+    test = parse(ArgTest, "thename", backend=backend)
     assert test.name == "thename"
 
 
-def test_path():
+@backends
+def test_path(backend):
     @dataclass
     class ArgTest:
         name: pathlib.PurePath
 
-    test = parse(ArgTest, "./file.txt")
+    test = parse(ArgTest, "./file.txt", backend=backend)
     assert test.name == pathlib.PurePath("./file.txt")
 
 
-def test_bool():
+@backends
+def test_bool(backend):
     @dataclass
     class ArgTest:
         flag: bool
 
-    test = parse(ArgTest, "--flag")
+    test = parse(ArgTest, "--flag", backend=backend)
     assert test.flag is True
 
 
-def test_tuple():
+@backends
+def test_tuple(backend):
     @dataclass
     class ArgTest:
         exact_num_args: Tuple[str, int] = field(
@@ -48,16 +52,18 @@ class ArgTest:
         "--exact-num-args",
         "three",
         "4",
+        backend=backend,
     )
     assert test.exact_num_args == ("three", 4)
 
 
-def test_missing_value_defaults():
+@backends
+def test_missing_value_defaults(backend):
     @dataclass
     class ArgTest:
         exact_num_args: Tuple[str, int] = field(
             default=("a", 0), metadata={"cappa": cappa.Arg(long=True)}
         )
 
-    test = parse(ArgTest)
+    test = parse(ArgTest, backend=backend)
     assert test.exact_num_args == ("a", 0)
diff --git a/tests/test_implicit_dataclass.py b/tests/test_implicit_dataclass.py
index c153630..900f8fb 100644
--- a/tests/test_implicit_dataclass.py
+++ b/tests/test_implicit_dataclass.py
@@ -2,7 +2,7 @@
 
 import cappa
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @cappa.command(name="sub")
@@ -11,6 +11,18 @@ class Example:
     name: str
 
 
-def test_invoke_top_level_command():
-    result = parse(Example, "4", "foo")
+@backends
+def test_invoke_top_level_command(backend):
+    result = parse(Example, "4", "foo", backend=backend)
+    assert result == Example(bar=4, name="foo")
+
+
+@backends
+def test_no_args_command(backend):
+    @cappa.command
+    class Example:
+        bar: int
+        name: str
+
+    result = parse(Example, "4", "foo", backend=backend)
     assert result == Example(bar=4, name="foo")
diff --git a/tests/test_manually_built.py b/tests/test_manually_built.py
index 7eaf5ff..436ceaa 100644
--- a/tests/test_manually_built.py
+++ b/tests/test_manually_built.py
@@ -4,8 +4,9 @@
 import cappa
 import pytest
 from cappa.annotation import parse_list, parse_value
+from cappa.output import Exit
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 @dataclass
@@ -25,21 +26,25 @@ class Foo:
 )
 
 
-def test_valid():
-    result = parse(command, "one", "2", "3")
+@backends
+def test_valid(backend):
+    result = parse(command, "one", "2", "3", backend=backend)
     assert result == Foo(bar="one", baz=[2, 3])
 
 
-def test_help(capsys):
-    with pytest.raises(ValueError):
-        parse(command, "-h")
+@pytest.mark.help
+@backends
+def test_help(capsys, backend):
+    with pytest.raises(Exit):
+        parse(command, "-h", backend=backend)
 
     out = capsys.readouterr().out
     assert "Short help." in out
     assert "Long description." in out
 
 
-def test_subcommand():
+@backends
+def test_subcommand(backend):
     @dataclass
     class Bar:
         bar: str
@@ -51,7 +56,7 @@ class Foo:
     command = cappa.Command(
         Foo,
         arguments=[
-            cappa.Subcommands(
+            cappa.Subcommand(
                 name="sub",
                 options={
                     "bar": cappa.Command(
@@ -67,5 +72,5 @@ class Foo:
         description="Long description.",
     )
 
-    result = parse(command, "bar", "one")
+    result = parse(command, "bar", "one", backend=backend)
     assert result == Foo(sub=Bar("one"))
diff --git a/tests/test_output.py b/tests/test_output.py
index a1aa55f..660eb11 100644
--- a/tests/test_output.py
+++ b/tests/test_output.py
@@ -3,23 +3,11 @@
 import cappa
 import pytest
 
+from tests.utils import backends
 
-def test_with_rich(capsys):
-    cappa.print("test", rich=True)
 
-    out = capsys.readouterr().err
-    assert out == "test\n"
-
-
-@pytest.mark.parametrize("flush", (True, False))
-def test_without_rich(capsys, flush):
-    cappa.print("test", rich=False, flush=flush)
-
-    out = capsys.readouterr().err
-    assert out == "test\n"
-
-
-def test_invoke_exit_success_with_message(capsys):
+@backends
+def test_invoke_exit_success_with_message(capsys, backend):
     def fn():
         raise cappa.Exit("With message")
 
@@ -29,13 +17,14 @@ class Example:
         ...
 
     with pytest.raises(cappa.Exit):
-        cappa.invoke(Example, argv=[""])
+        cappa.invoke(Example, argv=[""], backend=backend)
 
-    out = capsys.readouterr().err
+    out = capsys.readouterr().out
     assert out == "With message\n"
 
 
-def test_invoke_exit_success_without_message(capsys):
+@backends
+def test_invoke_exit_success_without_message(capsys, backend):
     def fn():
         raise cappa.Exit()
 
@@ -45,13 +34,14 @@ class Example:
         ...
 
     with pytest.raises(cappa.Exit):
-        cappa.invoke(Example, argv=[""])
+        cappa.invoke(Example, argv=[""], backend=backend)
 
-    out = capsys.readouterr().err
+    out = capsys.readouterr().out
     assert out == ""
 
 
-def test_invoke_exit_errror():
+@backends
+def test_invoke_exit_errror(capsys, backend):
     def fn():
         raise cappa.Exit("With message", code=1)
 
@@ -61,6 +51,8 @@ class Example:
         ...
 
     with pytest.raises(cappa.Exit) as e:
-        cappa.invoke(Example, argv=[""])
+        cappa.invoke(Example, argv=[""], backend=backend)
 
     assert e.value.code == 1
+    out = capsys.readouterr().err
+    assert out == "With message\n"
diff --git a/tests/test_pydantic.py b/tests/test_pydantic.py
index d1cdda7..d76e1ec 100644
--- a/tests/test_pydantic.py
+++ b/tests/test_pydantic.py
@@ -4,7 +4,7 @@
 from pydantic import BaseModel, dataclasses
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
 class PydanticCommand(BaseModel):
@@ -12,8 +12,9 @@ class PydanticCommand(BaseModel):
     foo: Annotated[int, cappa.Arg(short=True)]
 
 
-def test_base_model():
-    result = parse(PydanticCommand, "meow", "-f", "4")
+@backends
+def test_base_model(backend):
+    result = parse(PydanticCommand, "meow", "-f", "4", backend=backend)
     assert result == PydanticCommand(name="meow", foo=4)
 
 
@@ -23,6 +24,7 @@ class DataclassCommand:
     foo: Annotated[int, cappa.Arg(short=True)]
 
 
-def test_dataclass():
-    result = parse(DataclassCommand, "meow", "-f", "4")
+@backends
+def test_dataclass(backend):
+    result = parse(DataclassCommand, "meow", "-f", "4", backend=backend)
     assert result == DataclassCommand(name="meow", foo=4)
diff --git a/tests/test_rich.py b/tests/test_rich.py
index 92a2965..78c9058 100644
--- a/tests/test_rich.py
+++ b/tests/test_rich.py
@@ -1,50 +1,51 @@
 from dataclasses import dataclass
 
 import cappa
-from cappa import rich
+from cappa import output
 from typing_extensions import Annotated
 
-from tests.utils import parse
+from tests.utils import backends, parse
 
 
+@backends
 class TestTestPrompt:
-    def test_value(self):
-        prompt = rich.TestPrompt("gimme text number", default="one", input="two")
+    def test_value(self, backend):
+        prompt = output.TestPrompt("gimme text number", default="one", input="two")
 
         @dataclass
         class Test:
             name: Annotated[str, cappa.Arg(default=prompt)]
 
-        result = parse(Test)
+        result = parse(Test, backend=backend)
 
         assert result.name == "two"
 
         expected = "gimme text number (one): "
-        output = prompt.file.getvalue()
-        assert output == expected
+        out = prompt.file.getvalue()
+        assert out == expected
 
-    def test_default(self):
-        prompt = rich.TestPrompt("gimme text number", default="one", input="")
+    def test_default(self, backend):
+        prompt = output.TestPrompt("gimme text number", default="one", input="")
 
         @dataclass
         class Test:
             name: Annotated[str, cappa.Arg(default=prompt)]
 
-        result = parse(Test)
+        result = parse(Test, backend=backend)
 
         assert result.name == "one"
 
         expected = "gimme text number (one): "
-        output = prompt.file.getvalue()
-        assert output == expected
+        out = prompt.file.getvalue()
+        assert out == expected
 
-    def test_mapped(self):
-        prompt = rich.TestPrompt("gimme text number", default="1", input="5")
+    def test_mapped(self, backend):
+        prompt = output.TestPrompt("gimme text number", default="1", input="5")
 
         @dataclass
         class Test:
             num: Annotated[int, cappa.Arg(default=prompt)]
 
-        result = parse(Test)
+        result = parse(Test, backend=backend)
 
         assert result.num == 5
diff --git a/tests/utils.py b/tests/utils.py
index 695dbbc..546e958 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,16 +1,34 @@
-from cappa.testing import CommandRunner
-
+from typing import Union
+from unittest.mock import patch
 
-def value_error(message, code=127):
-    raise ValueError(message)
+import pytest
+from cappa.output import Exit
+from cappa.parser import backend
+from cappa.testing import CommandRunner
 
+backends = pytest.mark.parametrize("backend", [None, backend])
 
-runner = CommandRunner(base_args=["test.py"], exit_with=value_error)
+runner = CommandRunner(base_args=["test.py"])
 
 
-def parse(cls, *args):
-    return runner.parse(*args, obj=cls)
+def parse(cls, *args, backend=None):
+    return runner.parse(*args, obj=cls, backend=backend)
 
 
 def invoke(cls, *args, **kwargs):
-    return runner.invoke(*args, obj=cls, exit_with=value_error, **kwargs)
+    return runner.invoke(*args, obj=cls, **kwargs)
+
+
+def parse_completion(cls, *args, location=None) -> Union[str, None]:
+    env = {
+        "COMPLETION_LINE": " ".join(["test.py", *args]),
+        "COMPLETION_LOCATION": location if location is not None else len(args) + 1,
+    }
+    with patch("os.environ", new=env):
+        with pytest.raises(Exit) as e:
+            parse(cls, "--completion", "complete", backend=backend)
+
+        assert e.value.code == 0
+        if e.value.message:
+            return str(e.value.message)
+        return None