Repository: ihabunek/toot (language: python)

Issue:
Migrate from argparse to click
[Click](https://click.palletsprojects.com/en/8.1.x/options/) is a common alternative to argparse. And while I'm not a big fan of the annotations-based workflow, it would make several features trivial to implement, which are a pain using argparse. Most notably:

1. Passing default values [from environment variables](https://click.palletsprojects.com/en/8.1.x/options/#values-from-environment-variables)
2. Passing default values [from a config file](https://click.palletsprojects.com/en/8.1.x/commands/#overriding-defaults)
3. [Shell completion](https://click.palletsprojects.com/en/8.1.x/shell-completion/)

For their own list of benefits see:
https://click.palletsprojects.com/en/8.1.x/why/

There are also several contrib projects which sound like we'd want to use them, e.g. generate man pages:
https://github.com/click-contrib/

The downside is that it might be a bit slower than the current implementation. Currently we don't create the argparse definitions for all commands on each run, but rather only for the one command which is being executed. This is something which should be tested before commiting down this path.

The other downside is a pretty big rewrite of `console.py` which would require some work.



Discussion / hints:
and I just added another command line argument today :/ It makes sense to have unified handling of config file, command line and environment variables, so if click is the right tool for the job, then I guess it's worth the rewrite.

Fix patch:
diff --git a/.gitignore b/.gitignore
index 82e262f0..957a67bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,12 +6,14 @@
 /.env
 /.envrc
 /.pytest_cache/
+/book
 /build/
+/bundle/
 /dist/
 /htmlcov/
+/pyrightconfig.json
 /tmp/
+/toot-*.pyz
 /toot-*.tar.gz
-debug.log
-/pyrightconfig.json
 /venv/
-/book
+debug.log
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0203550..68945513 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,34 @@ Changelog
 
 <!-- Do not edit. This file is automatically generated from changelog.yaml.-->
 
+**0.40.0 (TBA)**
+
+This release includes a major rewrite to use
+[Click](https://click.palletsprojects.com/) for creating the command line
+interface. This allows for some new features like nested commands, setting
+parameters via environment variables, and shell completion. See docs for
+details. Backward compatibility should be mostly preserved, except for cases
+noted below please report any issues.
+
+* BREAKING: Remove deprecated `--disable-https` option for `login` and
+  `login_cli`, pass the base URL instead
+* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after
+  `toot` but before the command
+* Enable passing params via environment variables, see:
+  https://toot.bezdomni.net/environment_variables.html
+* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
+* Add `--json` option to tag commands
+* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
+  commands
+* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands,
+  deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`
+* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
+  list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
+  `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
+* Add `toot --width` option for setting your prefered terminal width
+* Add `--media-viewer` and `--colors` options to `toot tui`. These were
+  previously accessible only via settings.
+
 **0.39.0 (2023-11-23)**
 
 * Add `--json` option to many commands, this makes them print the JSON data
diff --git a/Makefile b/Makefile
index 438912b6..7500d130 100644
--- a/Makefile
+++ b/Makefile
@@ -15,12 +15,12 @@ test:
 coverage:
 	coverage erase
 	coverage run
-	coverage html
+	coverage html --omit "toot/tui/*"
 	coverage report
 
 clean :
 	find . -name "*pyc" | xargs rm -rf $1
-	rm -rf build dist MANIFEST htmlcov toot*.tar.gz
+	rm -rf build dist MANIFEST htmlcov bundle toot*.tar.gz toot*.pyz
 
 changelog:
 	./scripts/generate_changelog > CHANGELOG.md
@@ -30,7 +30,19 @@ docs: changelog
 	mdbook build
 
 docs-serve:
-	mdbook serve
+	mdbook serve --port 8000
 
 docs-deploy: docs
 	rsync --archive --compress --delete --stats book/ bezdomni:web/toot
+
+bundle:
+	mkdir bundle
+	cp toot/__main__.py bundle
+	pip install . --target=bundle
+	rm -rf bundle/*.dist-info
+	find bundle/ -type d -name "__pycache__" -exec rm -rf {} +
+	python -m zipapp \
+		--python "/usr/bin/env python3" \
+		--output toot-`git describe`.pyz bundle \
+		--compress
+	echo "Bundle created: toot-`git describe`.pyz"
diff --git a/changelog.yaml b/changelog.yaml
index 20961c2f..cf25955c 100644
--- a/changelog.yaml
+++ b/changelog.yaml
@@ -1,3 +1,23 @@
+0.40.0:
+  date: TBA
+  description: |
+    This release includes a major rewrite to use [Click](https://click.palletsprojects.com/) for
+    creating the command line interface. This allows for some new features like nested commands,
+    setting parameters via environment variables, and shell completion. See docs for details.
+    Backward compatibility should be mostly preserved, except for cases noted below please report
+    any issues.
+  changes:
+    - "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead"
+    - "BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after `toot` but before the command"
+    - "Enable passing params via environment variables, see: https://toot.bezdomni.net/environment_variables.html"
+    - "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html"
+    - "Add `--json` option to tag commands"
+    - "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands"
+    - "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`"
+    - "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands."
+    - "Add `toot --width` option for setting your prefered terminal width"
+    - "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings."
+
 0.39.0:
   date: 2023-11-23
   changes:
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 5c67529c..5fe913bd 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -6,6 +6,8 @@
 - [Usage](usage.md)
     - [Advanced](advanced.md)
     - [Settings](settings.md)
+    - [Shell completion](shell_completion.md)
+    - [Environment variables](environment_variables.md)
     - [TUI](tui.md)
 - [Contributing](contributing.md)
     - [Documentation](documentation.md)
diff --git a/docs/changelog.md b/docs/changelog.md
index d0203550..68945513 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -3,6 +3,34 @@ Changelog
 
 <!-- Do not edit. This file is automatically generated from changelog.yaml.-->
 
+**0.40.0 (TBA)**
+
+This release includes a major rewrite to use
+[Click](https://click.palletsprojects.com/) for creating the command line
+interface. This allows for some new features like nested commands, setting
+parameters via environment variables, and shell completion. See docs for
+details. Backward compatibility should be mostly preserved, except for cases
+noted below please report any issues.
+
+* BREAKING: Remove deprecated `--disable-https` option for `login` and
+  `login_cli`, pass the base URL instead
+* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after
+  `toot` but before the command
+* Enable passing params via environment variables, see:
+  https://toot.bezdomni.net/environment_variables.html
+* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
+* Add `--json` option to tag commands
+* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
+  commands
+* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands,
+  deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`
+* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
+  list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
+  `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
+* Add `toot --width` option for setting your prefered terminal width
+* Add `--media-viewer` and `--colors` options to `toot tui`. These were
+  previously accessible only via settings.
+
 **0.39.0 (2023-11-23)**
 
 * Add `--json` option to many commands, this makes them print the JSON data
diff --git a/docs/environment_variables.md b/docs/environment_variables.md
new file mode 100644
index 00000000..4ba05f78
--- /dev/null
+++ b/docs/environment_variables.md
@@ -0,0 +1,19 @@
+# Environment variables
+
+> Introduced in toot v0.40.0
+
+Toot allows setting defaults for parameters via environment variables.
+
+Environment variables should be named `TOOT_<COMMAND_NAME>_<OPTION_NAME>`.
+
+### Examples
+
+Command with option | Environment variable
+------------------- | --------------------
+`toot --color` | `TOOT_COLOR=true`
+`toot --no-color` | `TOOT_COLOR=false`
+`toot post --editor vim` | `TOOT_POST_EDITOR=vim`
+`toot post --visibility unlisted` | `TOOT_POST_VISIBILITY=unlisted`
+`toot tui --media-viewer feh` | `TOOT_TUI_MEDIA_VIEWER=feh`
+
+Note that these can also be set via the [settings file](./settings.html).
diff --git a/docs/shell_completion.md b/docs/shell_completion.md
new file mode 100644
index 00000000..d4086b89
--- /dev/null
+++ b/docs/shell_completion.md
@@ -0,0 +1,31 @@
+# Shell completion
+
+> Introduced in toot 0.40.0
+
+Toot uses [Click shell completion](https://click.palletsprojects.com/en/8.1.x/shell-completion/) which works on Bash, Fish and Zsh.
+
+To enable completion, toot must be [installed](./installation.html) as a command and available by ivoking `toot`. Then follow the instructions for your shell.
+
+**Bash**
+
+Add to `~/.bashrc`:
+
+```
+eval "$(_TOOT_COMPLETE=bash_source toot)"
+```
+
+**Fish**
+
+Add to `~/.config/fish/completions/toot.fish`:
+
+```
+_TOOT_COMPLETE=fish_source toot | source
+```
+
+**Zsh**
+
+Add to `~/.zshrc`:
+
+```
+eval "$(_TOOT_COMPLETE=zsh_source toot)"
+```
diff --git a/scripts/generate_changelog b/scripts/generate_changelog
index 5e6021e4..8aa5d6c1 100755
--- a/scripts/generate_changelog
+++ b/scripts/generate_changelog
@@ -21,6 +21,13 @@ for version in data.keys():
     changes = data[version]["changes"]
     print(f"**{version} ({date})**")
     print()
+
+    if "description" in data[version]:
+        description = data[version]["description"].strip()
+        for line in textwrap.wrap(description, 80):
+            print(line)
+        print()
+
     for c in changes:
         lines = textwrap.wrap(c, 78)
         initial = True
diff --git a/setup.py b/setup.py
index e11ad5c7..aca9b65f 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@
 
 setup(
     name='toot',
-    version='0.39.0',
+    version='0.40.0',
     description='Mastodon CLI client',
     long_description=long_description.strip(),
     author='Ivan Habunek',
@@ -31,9 +31,10 @@
         'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
         'Programming Language :: Python :: 3',
     ],
-    packages=['toot', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
+    packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
     python_requires=">=3.7",
     install_requires=[
+        "click~=8.1",
         "requests>=2.13,<3.0",
         "beautifulsoup4>=4.5.0,<5.0",
         "wcwidth>=0.1.7",
@@ -58,11 +59,12 @@
             "pytest-xdist[psutil]",
             "setuptools",
             "vermin",
+            "typing-extensions",
         ],
     },
     entry_points={
         'console_scripts': [
-            'toot=toot.console:main',
+            'toot=toot.cli:cli',
         ],
     }
 )
diff --git a/toot/__init__.py b/toot/__init__.py
index 9889d578..010b17a2 100644
--- a/toot/__init__.py
+++ b/toot/__init__.py
@@ -2,12 +2,23 @@
 import sys
 
 from os.path import join, expanduser
-from collections import namedtuple
+from typing import NamedTuple
 
-__version__ = '0.39.0'
+__version__ = '0.40.0'
+
+
+class App(NamedTuple):
+    instance: str
+    base_url: str
+    client_id: str
+    client_secret: str
+
+
+class User(NamedTuple):
+    instance: str
+    username: str
+    access_token: str
 
-App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
-User = namedtuple('User', ['instance', 'username', 'access_token'])
 
 DEFAULT_INSTANCE = 'https://mastodon.social'
 
diff --git a/toot/__main__.py b/toot/__main__.py
index abbb9e29..fa3e8074 100644
--- a/toot/__main__.py
+++ b/toot/__main__.py
@@ -1,3 +1,3 @@
-from .console import main
+from toot.cli import cli
 
-main()
+cli()
diff --git a/toot/api.py b/toot/api.py
index b2e82b70..c6aa91a5 100644
--- a/toot/api.py
+++ b/toot/api.py
@@ -8,7 +8,7 @@
 from urllib.parse import urlparse, urlencode, quote
 
 from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE
-from toot.exceptions import AuthenticationError, ConsoleError
+from toot.exceptions import ConsoleError
 from toot.utils import drop_empty_values, str_bool, str_bool_nullable
 
 
@@ -48,9 +48,9 @@ def _status_action(app, user, status_id, action, data=None) -> Response:
     return http.post(app, user, url, data=data)
 
 
-def _tag_action(app, user, tag_name, action):
+def _tag_action(app, user, tag_name, action) -> Response:
     url = f"/api/v1/tags/{tag_name}/{action}"
-    return http.post(app, user, url).json()
+    return http.post(app, user, url)
 
 
 def create_app(base_url):
@@ -140,7 +140,7 @@ def fetch_app_token(app):
     return http.anon_post(f"{app.base_url}/oauth/token", json=json).json()
 
 
-def login(app, username, password):
+def login(app: App, username: str, password: str):
     url = app.base_url + '/oauth/token'
 
     data = {
@@ -152,16 +152,10 @@ def login(app, username, password):
         'scope': SCOPES,
     }
 
-    response = http.anon_post(url, data=data, allow_redirects=False)
-
-    # If auth fails, it redirects to the login page
-    if response.is_redirect:
-        raise AuthenticationError()
+    return http.anon_post(url, data=data).json()
 
-    return response.json()
 
-
-def get_browser_login_url(app):
+def get_browser_login_url(app: App) -> str:
     """Returns the URL for manual log in via browser"""
     return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
         "response_type": "code",
@@ -171,7 +165,7 @@ def get_browser_login_url(app):
     }))
 
 
-def request_access_token(app, authorization_code):
+def request_access_token(app: App, authorization_code: str):
     url = app.base_url + '/oauth/token'
 
     data = {
@@ -306,6 +300,35 @@ def reblogged_by(app, user, status_id) -> Response:
     return http.get(app, user, url)
 
 
+def get_timeline_generator(
+    app: Optional[App],
+    user: Optional[User],
+    base_url: Optional[str] = None,
+    account: Optional[str] = None,
+    list_id: Optional[str] = None,
+    tag: Optional[str] = None,
+    local: bool = False,
+    public: bool = False,
+    limit=20,  # TODO
+):
+    if public:
+        if base_url:
+            return anon_public_timeline_generator(base_url, local=local, limit=limit)
+        else:
+            return public_timeline_generator(app, user, local=local, limit=limit)
+    elif tag:
+        if base_url:
+            return anon_tag_timeline_generator(base_url, tag, limit=limit)
+        else:
+            return tag_timeline_generator(app, user, tag, local=local, limit=limit)
+    elif account:
+        return account_timeline_generator(app, user, account, limit=limit)
+    elif list_id:
+        return timeline_list_generator(app, user, list_id, limit=limit)
+    else:
+        return home_timeline_generator(app, user, limit=limit)
+
+
 def _get_next_path(headers):
     """Given timeline response headers, returns the path to the next batch"""
     links = headers.get('Link', '')
@@ -315,6 +338,14 @@ def _get_next_path(headers):
         return "?".join([parsed.path, parsed.query])
 
 
+def _get_next_url(headers) -> Optional[str]:
+    """Given timeline response headers, returns the url to the next batch"""
+    links = headers.get('Link', '')
+    match = re.match('<([^>]+)>; rel="next"', links)
+    if match:
+        return match.group(1)
+
+
 def _timeline_generator(app, user, path, params=None):
     while path:
         response = http.get(app, user, path, params)
@@ -375,7 +406,7 @@ def conversation_timeline_generator(app, user, limit=20):
     return _conversation_timeline_generator(app, user, path, params)
 
 
-def account_timeline_generator(app: App, user: User, account_name: str, replies=False, reblogs=False, limit=20):
+def account_timeline_generator(app, user, account_name: str, replies=False, reblogs=False, limit=20):
     account = find_account(app, user, account_name)
     path = f"/api/v1/accounts/{account['id']}/statuses"
     params = {"limit": limit, "exclude_replies": not replies, "exclude_reblogs": not reblogs}
@@ -387,24 +418,23 @@ def timeline_list_generator(app, user, list_id, limit=20):
     return _timeline_generator(app, user, path, {'limit': limit})
 
 
-def _anon_timeline_generator(instance, path, params=None):
-    while path:
-        url = f"https://{instance}{path}"
+def _anon_timeline_generator(url, params=None):
+    while url:
         response = http.anon_get(url, params)
         yield response.json()
-        path = _get_next_path(response.headers)
+        url = _get_next_url(response.headers)
 
 
-def anon_public_timeline_generator(instance, local=False, limit=20):
-    path = '/api/v1/timelines/public'
-    params = {'local': str_bool(local), 'limit': limit}
-    return _anon_timeline_generator(instance, path, params)
+def anon_public_timeline_generator(base_url, local=False, limit=20):
+    query = urlencode({"local": str_bool(local), "limit": limit})
+    url = f"{base_url}/api/v1/timelines/public?{query}"
+    return _anon_timeline_generator(url)
 
 
-def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20):
-    path = f"/api/v1/timelines/tag/{quote(hashtag)}"
-    params = {'local': str_bool(local), 'limit': limit}
-    return _anon_timeline_generator(instance, path, params)
+def anon_tag_timeline_generator(base_url, hashtag, local=False, limit=20):
+    query = urlencode({"local": str_bool(local), "limit": limit})
+    url = f"{base_url}/api/v1/timelines/tag/{quote(hashtag)}?{query}"
+    return _anon_timeline_generator(url)
 
 
 def get_media(app: App, user: User, id: str):
@@ -427,7 +457,7 @@ def upload_media(
         "thumbnail": _add_mime_type(thumbnail)
     })
 
-    return http.post(app, user, "/api/v2/media", data=data, files=files).json()
+    return http.post(app, user, "/api/v2/media", data=data, files=files)
 
 
 def _add_mime_type(file):
@@ -469,11 +499,11 @@ def unfollow(app, user, account):
     return _account_action(app, user, account, 'unfollow')
 
 
-def follow_tag(app, user, tag_name):
+def follow_tag(app, user, tag_name) -> Response:
     return _tag_action(app, user, tag_name, 'follow')
 
 
-def unfollow_tag(app, user, tag_name):
+def unfollow_tag(app, user, tag_name) -> Response:
     return _tag_action(app, user, tag_name, 'unfollow')
 
 
@@ -501,6 +531,43 @@ def followed_tags(app, user):
     return _get_response_list(app, user, path)
 
 
+def featured_tags(app, user):
+    return http.get(app, user, "/api/v1/featured_tags")
+
+
+def feature_tag(app, user, tag: str) -> Response:
+    return http.post(app, user, "/api/v1/featured_tags", data={"name": tag})
+
+
+def unfeature_tag(app, user, tag_id: str) -> Response:
+    return http.delete(app, user, f"/api/v1/featured_tags/{tag_id}")
+
+
+def find_tag(app, user, tag) -> Optional[dict]:
+    """Find a hashtag by tag name or ID"""
+    tag = tag.lstrip("#")
+    results = search(app, user, tag, type="hashtags").json()
+
+    return next(
+        (
+            t for t in results["hashtags"]
+            if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
+        ),
+        None
+    )
+
+
+def find_featured_tag(app, user, tag) -> Optional[dict]:
+    """Find a featured tag by tag name or ID"""
+    return next(
+        (
+            t for t in featured_tags(app, user).json()
+            if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
+        ),
+        None
+    )
+
+
 def whois(app, user, account):
     return http.get(app, user, f'/api/v1/accounts/{account}').json()
 
@@ -544,8 +611,8 @@ def verify_credentials(app, user) -> Response:
     return http.get(app, user, '/api/v1/accounts/verify_credentials')
 
 
-def get_notifications(app, user, exclude_types=[], limit=20):
-    params = {"exclude_types[]": exclude_types, "limit": limit}
+def get_notifications(app, user, types=[], exclude_types=[], limit=20):
+    params = {"types[]": types, "exclude_types[]": exclude_types, "limit": limit}
     return http.get(app, user, '/api/v1/notifications', params).json()
 
 
@@ -559,16 +626,7 @@ def get_instance(base_url: str) -> Response:
 
 
 def get_lists(app, user):
-    path = "/api/v1/lists"
-    return _get_response_list(app, user, path)
-
-
-def find_list_id(app, user, title):
-    lists = get_lists(app, user)
-    for list_item in lists:
-        if list_item["title"] == title:
-            return list_item["id"]
-    return None
+    return http.get(app, user, "/api/v1/lists").json()
 
 
 def get_list_accounts(app, user, list_id):
@@ -576,7 +634,7 @@ def get_list_accounts(app, user, list_id):
     return _get_response_list(app, user, path)
 
 
-def create_list(app, user, title, replies_policy):
+def create_list(app, user, title, replies_policy="none"):
     url = "/api/v1/lists"
     json = {'title': title}
     if replies_policy:
diff --git a/toot/auth.py b/toot/auth.py
index 0311013f..ef84652a 100644
--- a/toot/auth.py
+++ b/toot/auth.py
@@ -1,18 +1,19 @@
-import sys
-import webbrowser
-
-from builtins import input
-from getpass import getpass
-
-from toot import api, config, DEFAULT_INSTANCE, User, App
+from toot import api, config, User, App
+from toot.entities import from_dict, Instance
 from toot.exceptions import ApiError, ConsoleError
-from toot.output import print_out
 from urllib.parse import urlparse
 
 
-def register_app(domain, base_url):
+def find_instance(base_url: str) -> Instance:
+    try:
+        instance = api.get_instance(base_url).json()
+        return from_dict(Instance, instance)
+    except Exception:
+        raise ConsoleError(f"Instance not found at {base_url}")
+
+
+def register_app(domain: str, base_url: str) -> App:
     try:
-        print_out("Registering application...")
         response = api.create_app(base_url)
     except ApiError:
         raise ConsoleError("Registration failed.")
@@ -20,114 +21,54 @@ def register_app(domain, base_url):
     app = App(domain, base_url, response['client_id'], response['client_secret'])
     config.save_app(app)
 
-    print_out("Application tokens saved.")
-
     return app
 
 
-def create_app_interactive(base_url):
-    if not base_url:
-        print_out(f"Enter instance URL [<green>{DEFAULT_INSTANCE}</green>]: ", end="")
-        base_url = input()
-        if not base_url:
-            base_url = DEFAULT_INSTANCE
-
-    domain = get_instance_domain(base_url)
-
+def get_or_create_app(base_url: str) -> App:
+    instance = find_instance(base_url)
+    domain = _get_instance_domain(instance)
     return config.load_app(domain) or register_app(domain, base_url)
 
 
-def get_instance_domain(base_url):
-    print_out("Looking up instance info...")
-
-    instance = api.get_instance(base_url).json()
-
-    print_out(
-        f"Found instance <blue>{instance['title']}</blue> "
-        f"running Mastodon version <yellow>{instance['version']}</yellow>"
-    )
-
-    # Pleroma and its forks return an actual URI here, rather than a
-    # domain name like Mastodon. This is contrary to the spec.¯
-    # in that case, parse out the domain and return it.
-
-    parsed_uri = urlparse(instance["uri"])
-
-    if parsed_uri.netloc:
-        # Pleroma, Akkoma, GotoSocial, etc.
-        return parsed_uri.netloc
-    else:
-        # Others including Mastodon servers
-        return parsed_uri.path
-
-    # NB: when updating to v2 instance endpoint, this field has been renamed to `domain`
-
-
-def create_user(app, access_token):
+def create_user(app: App, access_token: str) -> User:
     # Username is not yet known at this point, so fetch it from Mastodon
     user = User(app.instance, None, access_token)
     creds = api.verify_credentials(app, user).json()
 
-    user = User(app.instance, creds['username'], access_token)
+    user = User(app.instance, creds["username"], access_token)
     config.save_user(user, activate=True)
 
-    print_out("Access token saved to config at: <green>{}</green>".format(
-        config.get_config_file_path()))
-
     return user
 
 
-def login_interactive(app, email=None):
-    print_out("Log in to <green>{}</green>".format(app.instance))
-
-    if email:
-        print_out("Email: <green>{}</green>".format(email))
-
-    while not email:
-        email = input('Email: ')
-
-    # Accept password piped from stdin, useful for testing purposes but not
-    # documented so people won't get ideas. Otherwise prompt for password.
-    if sys.stdin.isatty():
-        password = getpass('Password: ')
-    else:
-        password = sys.stdin.read().strip()
-        print_out("Password: <green>read from stdin</green>")
-
+def login_username_password(app: App, email: str, password: str) -> User:
     try:
-        print_out("Authenticating...")
         response = api.login(app, email, password)
-    except ApiError:
+    except Exception:
         raise ConsoleError("Login failed")
 
-    return create_user(app, response['access_token'])
-
+    return create_user(app, response["access_token"])
 
-BROWSER_LOGIN_EXPLANATION = """
-This authentication method requires you to log into your Mastodon instance
-in your browser, where you will be asked to authorize <yellow>toot</yellow> to access
-your account. When you do, you will be given an <yellow>authorization code</yellow>
-which you need to paste here.
-"""
 
+def login_auth_code(app: App, authorization_code: str) -> User:
+    try:
+        response = api.request_access_token(app, authorization_code)
+    except Exception:
+        raise ConsoleError("Login failed")
 
-def login_browser_interactive(app):
-    url = api.get_browser_login_url(app)
-    print_out(BROWSER_LOGIN_EXPLANATION)
-
-    print_out("This is the login URL:")
-    print_out(url)
-    print_out("")
+    return create_user(app, response["access_token"])
 
-    yesno = input("Open link in default browser? [Y/n]")
-    if not yesno or yesno.lower() == 'y':
-        webbrowser.open(url)
 
-    authorization_code = ""
-    while not authorization_code:
-        authorization_code = input("Authorization code: ")
+def _get_instance_domain(instance: Instance) -> str:
+    """Extracts the instance domain name.
 
-    print_out("\nRequesting access token...")
-    response = api.request_access_token(app, authorization_code)
+    Pleroma and its forks return an actual URI here, rather than a domain name
+    like Mastodon. This is contrary to the spec.¯ in that case, parse out the
+    domain and return it.
 
-    return create_user(app, response['access_token'])
+    TODO: when updating to v2 instance endpoint, this field has been renamed to
+    `domain`
+    """
+    if instance.uri.startswith("http"):
+        return urlparse(instance.uri).netloc
+    return instance.uri
diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py
new file mode 100644
index 00000000..0e345acd
--- /dev/null
+++ b/toot/cli/__init__.py
@@ -0,0 +1,141 @@
+import click
+import logging
+import os
+import sys
+import typing as t
+
+from click.testing import Result
+from functools import wraps
+from toot import App, User, config, __version__
+from toot.settings import get_settings
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+    P = te.ParamSpec("P")
+
+R = t.TypeVar("R")
+T = t.TypeVar("T")
+
+
+PRIVACY_CHOICES = ["public", "unlisted", "private"]
+VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
+
+TUI_COLORS = {
+    "1": 1,
+    "16": 16,
+    "88": 88,
+    "256": 256,
+    "16777216": 16777216,
+    "24bit": 16777216,
+}
+TUI_COLORS_CHOICES = list(TUI_COLORS.keys())
+TUI_COLORS_VALUES = list(TUI_COLORS.values())
+
+DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
+seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
+
+
+# Type alias for run commands
+Run = t.Callable[..., Result]
+
+
+def get_default_visibility() -> str:
+    return os.getenv("TOOT_POST_VISIBILITY", "public")
+
+
+def get_default_map():
+    settings = get_settings()
+    common = settings.get("common", {})
+    commands = settings.get("commands", {})
+    return {**common, **commands}
+
+
+# Tweak the Click context
+# https://click.palletsprojects.com/en/8.1.x/api/#context
+CONTEXT = dict(
+    # Enable using environment variables to set options
+    auto_envvar_prefix="TOOT",
+    # Add shorthand -h for invoking help
+    help_option_names=["-h", "--help"],
+    # Always show default values for options
+    show_default=True,
+    # Load command defaults from settings
+    default_map=get_default_map(),
+)
+
+
+class Context(t.NamedTuple):
+    app: t.Optional[App]
+    user: t.Optional[User] = None
+    color: bool = False
+    debug: bool = False
+    quiet: bool = False
+
+
+class TootObj(t.NamedTuple):
+    """Data to add to Click context"""
+    color: bool = True
+    debug: bool = False
+    quiet: bool = False
+    # Pass a context for testing purposes
+    test_ctx: t.Optional[Context] = None
+
+
+def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
+    """Pass the toot Context as first argument."""
+    @wraps(f)
+    def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R:
+        return f(_get_context(), *args, **kwargs)
+
+    return wrapped
+
+
+def _get_context() -> Context:
+    click_context = click.get_current_context()
+    obj: TootObj = click_context.obj
+
+    # This is used to pass a context for testing, not used in normal usage
+    if obj.test_ctx:
+        return obj.test_ctx
+
+    user, app = config.get_active_user_app()
+    if not user or not app:
+        raise click.ClickException("This command requires you to be logged in.")
+
+    return Context(app, user, obj.color, obj.debug, obj.quiet)
+
+
+json_option = click.option(
+    "--json",
+    is_flag=True,
+    default=False,
+    help="Print data as JSON rather than human readable text"
+)
+
+
+@click.group(context_settings=CONTEXT)
+@click.option("-w", "--max-width", type=int, default=80, help="Maximum width for content rendered by toot")
+@click.option("--debug/--no-debug", default=False, help="Log debug info to stderr")
+@click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output")
+@click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout")
+@click.version_option(__version__, message="%(prog)s v%(version)s")
+@click.pass_context
+def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, quiet: bool):
+    """Toot is a Mastodon CLI"""
+    ctx.obj = TootObj(color, debug, quiet)
+    ctx.color = color
+    ctx.max_content_width = max_width
+
+    if debug:
+        logging.basicConfig(level=logging.DEBUG)
+
+
+from toot.cli import accounts  # noqa
+from toot.cli import auth  # noqa
+from toot.cli import lists  # noqa
+from toot.cli import post  # noqa
+from toot.cli import read  # noqa
+from toot.cli import statuses  # noqa
+from toot.cli import tags  # noqa
+from toot.cli import timelines  # noqa
+from toot.cli import tui  # noqa
diff --git a/toot/cli/accounts.py b/toot/cli/accounts.py
new file mode 100644
index 00000000..01c499dc
--- /dev/null
+++ b/toot/cli/accounts.py
@@ -0,0 +1,257 @@
+import click
+import json as pyjson
+
+from typing import BinaryIO, Optional
+
+from toot import api
+from toot.cli import PRIVACY_CHOICES, cli, json_option, Context, pass_context
+from toot.cli.validators import validate_language
+from toot.output import print_acct_list
+
+
+@cli.command(name="update_account")
+@click.option("--display-name", help="The display name to use for the profile.")
+@click.option("--note", help="The account bio.")
+@click.option(
+    "--avatar",
+    type=click.File(mode="rb"),
+    help="Path to the avatar image to set.",
+)
+@click.option(
+    "--header",
+    type=click.File(mode="rb"),
+    help="Path to the header image to set.",
+)
+@click.option(
+    "--bot/--no-bot",
+    default=None,
+    help="Whether the account has a bot flag.",
+)
+@click.option(
+    "--discoverable/--no-discoverable",
+    default=None,
+    help="Whether the account should be shown in the profile directory.",
+)
+@click.option(
+    "--locked/--no-locked",
+    default=None,
+    help="Whether manual approval of follow requests is required.",
+)
+@click.option(
+    "--privacy",
+    type=click.Choice(PRIVACY_CHOICES),
+    help="Default post privacy for authored statuses.",
+)
+@click.option(
+    "--sensitive/--no-sensitive",
+    default=None,
+    help="Whether to mark authored statuses as sensitive by default.",
+)
+@click.option(
+    "--language",
+    callback=validate_language,
+    help="Default language to use for authored statuses (ISO 639-1).",
+)
+@json_option
+@pass_context
+def update_account(
+    ctx: Context,
+    display_name: Optional[str],
+    note: Optional[str],
+    avatar: Optional[BinaryIO],
+    header: Optional[BinaryIO],
+    bot: Optional[bool],
+    discoverable: Optional[bool],
+    locked: Optional[bool],
+    privacy: Optional[bool],
+    sensitive: Optional[bool],
+    language: Optional[bool],
+    json: bool,
+):
+    """Update your account details"""
+    options = [
+        avatar,
+        bot,
+        discoverable,
+        display_name,
+        header,
+        language,
+        locked,
+        note,
+        privacy,
+        sensitive,
+    ]
+
+    if all(option is None for option in options):
+        raise click.ClickException("Please specify at least one option to update the account")
+
+    response = api.update_account(
+        ctx.app,
+        ctx.user,
+        avatar=avatar,
+        bot=bot,
+        discoverable=discoverable,
+        display_name=display_name,
+        header=header,
+        language=language,
+        locked=locked,
+        note=note,
+        privacy=privacy,
+        sensitive=sensitive,
+    )
+
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Account updated", fg="green")
+
+
+@cli.command()
+@click.argument("account")
+@json_option
+@pass_context
+def follow(ctx: Context, account: str, json: bool):
+    """Follow an account"""
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    response = api.follow(ctx.app, ctx.user, found_account["id"])
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ You are now following {account}", fg="green")
+
+
+@cli.command()
+@click.argument("account")
+@json_option
+@pass_context
+def unfollow(ctx: Context, account: str, json: bool):
+    """Unfollow an account"""
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    response = api.unfollow(ctx.app, ctx.user, found_account["id"])
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ You are no longer following {account}", fg="green")
+
+
+@cli.command()
+@click.argument("account", required=False)
+@json_option
+@pass_context
+def following(ctx: Context, account: Optional[str], json: bool):
+    """List accounts followed by an account.
+
+    If no account is given list accounts followed by you.
+    """
+    account = account or ctx.user.username
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    accounts = api.following(ctx.app, ctx.user, found_account["id"])
+    if json:
+        click.echo(pyjson.dumps(accounts))
+    else:
+        print_acct_list(accounts)
+
+
+@cli.command()
+@click.argument("account", required=False)
+@json_option
+@pass_context
+def followers(ctx: Context, account: Optional[str], json: bool):
+    """List accounts following an account.
+
+    If no account given list accounts following you."""
+    account = account or ctx.user.username
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    accounts = api.followers(ctx.app, ctx.user, found_account["id"])
+    if json:
+        click.echo(pyjson.dumps(accounts))
+    else:
+        print_acct_list(accounts)
+
+
+@cli.command()
+@click.argument("account")
+@json_option
+@pass_context
+def mute(ctx: Context, account: str, json: bool):
+    """Mute an account"""
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    response = api.mute(ctx.app, ctx.user, found_account["id"])
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ You have muted {account}", fg="green")
+
+
+@cli.command()
+@click.argument("account")
+@json_option
+@pass_context
+def unmute(ctx: Context, account: str, json: bool):
+    """Unmute an account"""
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    response = api.unmute(ctx.app, ctx.user, found_account["id"])
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ {account} is no longer muted", fg="green")
+
+
+@cli.command()
+@json_option
+@pass_context
+def muted(ctx: Context, json: bool):
+    """List muted accounts"""
+    response = api.muted(ctx.app, ctx.user)
+    if json:
+        click.echo(pyjson.dumps(response))
+    else:
+        if len(response) > 0:
+            click.echo("Muted accounts:")
+            print_acct_list(response)
+        else:
+            click.echo("No accounts muted")
+
+
+@cli.command()
+@click.argument("account")
+@json_option
+@pass_context
+def block(ctx: Context, account: str, json: bool):
+    """Block an account"""
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    response = api.block(ctx.app, ctx.user, found_account["id"])
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ You are now blocking {account}", fg="green")
+
+
+@cli.command()
+@click.argument("account")
+@json_option
+@pass_context
+def unblock(ctx: Context, account: str, json: bool):
+    """Unblock an account"""
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    response = api.unblock(ctx.app, ctx.user, found_account["id"])
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ {account} is no longer blocked", fg="green")
+
+
+@cli.command()
+@json_option
+@pass_context
+def blocked(ctx: Context, json: bool):
+    """List blocked accounts"""
+    response = api.blocked(ctx.app, ctx.user)
+    if json:
+        click.echo(pyjson.dumps(response))
+    else:
+        if len(response) > 0:
+            click.echo("Blocked accounts:")
+            print_acct_list(response)
+        else:
+            click.echo("No accounts blocked")
diff --git a/toot/cli/auth.py b/toot/cli/auth.py
new file mode 100644
index 00000000..c72f0c46
--- /dev/null
+++ b/toot/cli/auth.py
@@ -0,0 +1,158 @@
+import click
+import platform
+import sys
+import webbrowser
+from click.shell_completion import CompletionItem
+
+from click.types import StringParamType
+
+from toot import api, config, __version__
+from toot.auth import get_or_create_app, login_auth_code, login_username_password
+from toot.cli import cli
+from toot.cli.validators import validate_instance
+
+
+instance_option = click.option(
+    "--instance", "-i", "base_url",
+    prompt="Enter instance URL",
+    default="https://mastodon.social",
+    callback=validate_instance,
+    help="""Domain or base URL of the instance to log into,
+            e.g. 'mastodon.social' or 'https://mastodon.social'""",
+)
+
+
+class AccountParamType(StringParamType):
+    """Custom type to add shell completion for account names"""
+
+    def shell_complete(self, ctx, param, incomplete: str):
+        accounts = config.load_config()["users"].keys()
+        return [
+            CompletionItem(a)
+            for a in accounts
+            if a.lower().startswith(incomplete.lower())
+        ]
+
+
+@cli.command()
+def auth():
+    """Show logged in accounts and instances"""
+    config_data = config.load_config()
+
+    if not config_data["users"]:
+        click.echo("You are not logged in to any accounts")
+        return
+
+    active_user = config_data["active_user"]
+
+    click.echo("Authenticated accounts:")
+    for uid, u in config_data["users"].items():
+        active_label = "ACTIVE" if active_user == uid else ""
+        uid = click.style(uid, fg="green")
+        active_label = click.style(active_label, fg="yellow")
+        click.echo(f"* {uid} {active_label}")
+
+    path = config.get_config_file_path()
+    path = click.style(path, "blue")
+    click.echo(f"\nAuth tokens are stored in: {path}")
+
+
+@cli.command()
+def env():
+    """Print environment information for inclusion in bug reports."""
+    click.echo(f"toot {__version__}")
+    click.echo(f"Python {sys.version}")
+    click.echo(platform.platform())
+
+
+@cli.command(name="login_cli")
+@instance_option
+@click.option("--email", "-e", help="Email address to log in with", prompt=True)
+@click.option("--password", "-p", hidden=True, prompt=True, hide_input=True)
+def login_cli(base_url: str, email: str, password: str):
+    """
+    Log into an instance from the console (not recommended)
+
+    Does NOT support two factor authentication, may not work on instances
+    other than Mastodon, mostly useful for scripting.
+    """
+    app = get_or_create_app(base_url)
+    login_username_password(app, email, password)
+
+    click.secho("✓ Successfully logged in.", fg="green")
+    click.echo("Access token saved to config at: ", nl=False)
+    click.secho(config.get_config_file_path(), fg="green")
+
+
+LOGIN_EXPLANATION = """This authentication method requires you to log into your
+Mastodon instance in your browser, where you will be asked to authorize toot to
+access your account. When you do, you will be given an authorization code which
+you need to paste here.""".replace("\n", " ")
+
+
+@cli.command()
+@instance_option
+def login(base_url: str):
+    """Log into an instance using your browser (recommended)"""
+    app = get_or_create_app(base_url)
+    url = api.get_browser_login_url(app)
+
+    click.echo(click.wrap_text(LOGIN_EXPLANATION))
+    click.echo("\nLogin URL:")
+    click.echo(url)
+
+    yesno = click.prompt("Open link in default browser? [Y/n]", default="Y", show_default=False)
+    if not yesno or yesno.lower() == 'y':
+        webbrowser.open(url)
+
+    authorization_code = ""
+    while not authorization_code:
+        authorization_code = click.prompt("Authorization code")
+
+    login_auth_code(app, authorization_code)
+
+    click.echo()
+    click.secho("✓ Successfully logged in.", fg="green")
+
+
+@cli.command()
+@click.argument("account", type=AccountParamType(), required=False)
+def logout(account: str):
+    """Log out of ACCOUNT, delete stored access keys"""
+    accounts = _get_accounts_list()
+
+    if not account:
+        raise click.ClickException(f"Specify account to log out:\n{accounts}")
+
+    user = config.load_user(account)
+
+    if not user:
+        raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
+
+    config.delete_user(user)
+    click.secho(f"✓ Account {account} logged out", fg="green")
+
+
+@cli.command()
+@click.argument("account", type=AccountParamType(), required=False)
+def activate(account: str):
+    """Switch to logged in ACCOUNT."""
+    accounts = _get_accounts_list()
+
+    if not account:
+        raise click.ClickException(f"Specify account to activate:\n{accounts}")
+
+    user = config.load_user(account)
+
+    if not user:
+        raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
+
+    config.activate_user(user)
+    click.secho(f"✓ Account {account} activated", fg="green")
+
+
+def _get_accounts_list() -> str:
+    accounts = config.load_config()["users"].keys()
+    if not accounts:
+        raise click.ClickException("You're not logged into any accounts")
+    return "\n".join([f"* {acct}" for acct in accounts])
diff --git a/toot/cli/lists.py b/toot/cli/lists.py
new file mode 100644
index 00000000..a7a8fcca
--- /dev/null
+++ b/toot/cli/lists.py
@@ -0,0 +1,218 @@
+import click
+
+from toot import api, config
+from toot.cli import Context, cli, pass_context
+from toot.output import print_list_accounts, print_lists, print_warning
+
+
+@cli.group(invoke_without_command=True)
+@click.pass_context
+def lists(ctx: click.Context):
+    """Display and manage lists"""
+    if ctx.invoked_subcommand is None:
+        print_warning("`toot lists` is deprecated in favour of `toot lists list`")
+
+        user, app = config.get_active_user_app()
+        if not user or not app:
+            raise click.ClickException("This command requires you to be logged in.")
+
+        lists = api.get_lists(app, user)
+        if lists:
+            print_lists(lists)
+        else:
+            click.echo("You have no lists defined.")
+
+
+@lists.command()
+@pass_context
+def list(ctx: Context):
+    """List all your lists"""
+    lists = api.get_lists(ctx.app, ctx.user)
+
+    if lists:
+        print_lists(lists)
+    else:
+        click.echo("You have no lists defined.")
+
+
+@lists.command()
+@click.argument("title", required=False)
+@click.option("--id", help="List ID if not title is given")
+@pass_context
+def accounts(ctx: Context, title: str, id: str):
+    """List the accounts in a list"""
+    list_id = _get_list_id(ctx, title, id)
+    response = api.get_list_accounts(ctx.app, ctx.user, list_id)
+    print_list_accounts(response)
+
+
+@lists.command()
+@click.argument("title")
+@click.option(
+    "--replies-policy",
+    type=click.Choice(["followed", "list", "none"]),
+    default="none",
+    help="Replies policy"
+)
+@pass_context
+def create(ctx: Context, title: str, replies_policy: str):
+    """Create a list"""
+    api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy)
+    click.secho(f"✓ List \"{title}\" created.", fg="green")
+
+
+@lists.command()
+@click.argument("title", required=False)
+@click.option("--id", help="List ID if not title is given")
+@pass_context
+def delete(ctx: Context, title: str, id: str):
+    """Delete a list"""
+    list_id = _get_list_id(ctx, title, id)
+    api.delete_list(ctx.app, ctx.user, list_id)
+    click.secho(f"✓ List \"{title if title else id}\" deleted.", fg="green")
+
+
+@lists.command()
+@click.argument("title", required=False)
+@click.argument("account")
+@click.option("--id", help="List ID if not title is given")
+@pass_context
+def add(ctx: Context, title: str, account: str, id: str):
+    """Add an account to a list"""
+    list_id = _get_list_id(ctx, title, id)
+    found_account = api.find_account(ctx.app, ctx.user, account)
+
+    try:
+        api.add_accounts_to_list(ctx.app, ctx.user, list_id, [found_account["id"]])
+    except Exception:
+        # TODO: this is slow, improve
+        # if we failed to add the account, try to give a
+        # more specific error message than "record not found"
+        my_accounts = api.followers(ctx.app, ctx.user, found_account["id"])
+        found = False
+        if my_accounts:
+            for my_account in my_accounts:
+                if my_account["id"] == found_account["id"]:
+                    found = True
+                    break
+        if found is False:
+            raise click.ClickException(f"You must follow @{account} before adding this account to a list.")
+        raise
+
+    click.secho(f"✓ Added account \"{account}\"", fg="green")
+
+
+@lists.command()
+@click.argument("title", required=False)
+@click.argument("account")
+@click.option("--id", help="List ID if not title is given")
+@pass_context
+def remove(ctx: Context, title: str, account: str, id: str):
+    """Remove an account from a list"""
+    list_id = _get_list_id(ctx, title, id)
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]])
+    click.secho(f"✓ Removed account \"{account}\"", fg="green")
+
+
+@cli.command(name="list_accounts", hidden=True)
+@click.argument("title", required=False)
+@click.option("--id", help="List ID if not title is given")
+@pass_context
+def list_accounts(ctx: Context, title: str, id: str):
+    """List the accounts in a list"""
+    print_warning("`toot list_accounts` is deprecated in favour of `toot lists accounts`")
+    list_id = _get_list_id(ctx, title, id)
+    response = api.get_list_accounts(ctx.app, ctx.user, list_id)
+    print_list_accounts(response)
+
+
+@cli.command(name="list_create", hidden=True)
+@click.argument("title")
+@click.option(
+    "--replies-policy",
+    type=click.Choice(["followed", "list", "none"]),
+    default="none",
+    help="Replies policy"
+)
+@pass_context
+def list_create(ctx: Context, title: str, replies_policy: str):
+    """Create a list"""
+    print_warning("`toot list_create` is deprecated in favour of `toot lists create`")
+    api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy)
+    click.secho(f"✓ List \"{title}\" created.", fg="green")
+
+
+@cli.command(name="list_delete", hidden=True)
+@click.argument("title", required=False)
+@click.option("--id", help="List ID if not title is given")
+@pass_context
+def list_delete(ctx: Context, title: str, id: str):
+    """Delete a list"""
+    print_warning("`toot list_delete` is deprecated in favour of `toot lists delete`")
+    list_id = _get_list_id(ctx, title, id)
+    api.delete_list(ctx.app, ctx.user, list_id)
+    click.secho(f"✓ List \"{title if title else id}\" deleted.", fg="green")
+
+
+@cli.command(name="list_add", hidden=True)
+@click.argument("title", required=False)
+@click.argument("account")
+@click.option("--id", help="List ID if not title is given")
+@pass_context
+def list_add(ctx: Context, title: str, account: str, id: str):
+    """Add an account to a list"""
+    print_warning("`toot list_add` is deprecated in favour of `toot lists add`")
+    list_id = _get_list_id(ctx, title, id)
+    found_account = api.find_account(ctx.app, ctx.user, account)
+
+    try:
+        api.add_accounts_to_list(ctx.app, ctx.user, list_id, [found_account["id"]])
+    except Exception:
+        # if we failed to add the account, try to give a
+        # more specific error message than "record not found"
+        my_accounts = api.followers(ctx.app, ctx.user, found_account["id"])
+        found = False
+        if my_accounts:
+            for my_account in my_accounts:
+                if my_account["id"] == found_account["id"]:
+                    found = True
+                    break
+        if found is False:
+            raise click.ClickException(f"You must follow @{account} before adding this account to a list.")
+        raise
+
+    click.secho(f"✓ Added account \"{account}\"", fg="green")
+
+
+@cli.command(name="list_remove", hidden=True)
+@click.argument("title", required=False)
+@click.argument("account")
+@click.option("--id", help="List ID if not title is given")
+@pass_context
+def list_remove(ctx: Context, title: str, account: str, id: str):
+    """Remove an account from a list"""
+    print_warning("`toot list_remove` is deprecated in favour of `toot lists remove`")
+    list_id = _get_list_id(ctx, title, id)
+    found_account = api.find_account(ctx.app, ctx.user, account)
+    api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]])
+    click.secho(f"✓ Removed account \"{account}\"", fg="green")
+
+
+def _get_list_id(ctx: Context, title, list_id):
+    if not list_id and not title:
+        raise click.ClickException("Please specify list title or ID")
+
+    lists = api.get_lists(ctx.app, ctx.user)
+    matched_ids = [
+        list["id"] for list in lists
+        if list["title"].lower() == title.lower() or list["id"] == list_id
+    ]
+
+    if not matched_ids:
+        raise click.ClickException("List not found")
+
+    if len(matched_ids) > 1:
+        raise click.ClickException("Found multiple lists with the same title, please specify the ID instead")
+
+    return matched_ids[0]
diff --git a/toot/cli/post.py b/toot/cli/post.py
new file mode 100644
index 00000000..af0fa602
--- /dev/null
+++ b/toot/cli/post.py
@@ -0,0 +1,281 @@
+import click
+import os
+import sys
+
+from datetime import datetime, timedelta, timezone
+from time import sleep, time
+from typing import BinaryIO, Optional, Tuple
+
+from toot import api
+from toot.cli import cli, json_option, pass_context, Context
+from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES
+from toot.cli.validators import validate_duration, validate_language
+from toot.entities import MediaAttachment, from_dict
+from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input
+from toot.utils.datetime import parse_datetime
+
+
+@cli.command()
+@click.argument("text", required=False)
+@click.option(
+    "--media", "-m",
+    help="""Path to media file to attach, can be used multiple times to attach
+            multiple files.""",
+    type=click.File(mode="rb"),
+    multiple=True
+)
+@click.option(
+    "--description", "-d", "descriptions",
+    help="""Plain-text description of the media for accessibility purposes, one
+            per attached media""",
+    multiple=True,
+)
+@click.option(
+    "--thumbnail", "thumbnails",
+    help="Path to an image file to serve as media thumbnail, one per attached media",
+    type=click.File(mode="rb"),
+    multiple=True
+)
+@click.option(
+    "--visibility", "-v",
+    help="Post visibility",
+    type=click.Choice(VISIBILITY_CHOICES),
+    default="public",
+)
+@click.option(
+    "--sensitive", "-s",
+    help="Mark status and attached media as sensitive",
+    default=False,
+    is_flag=True,
+)
+@click.option(
+    "--spoiler-text", "-p",
+    help="Text to be shown as a warning or subject before the actual content.",
+)
+@click.option(
+    "--reply-to", "-r",
+    help="ID of the status being replied to, if status is a reply.",
+)
+@click.option(
+    "--language", "-l",
+    help="ISO 639-1 language code of the toot, to skip automatic detection.",
+    callback=validate_language,
+)
+@click.option(
+    "--editor", "-e",
+    is_flag=False,
+    flag_value=os.getenv("EDITOR"),
+    help="""Specify an editor to compose your toot. When used without a value
+            it will use the editor defined in the $EDITOR environment variable.""",
+)
+@click.option(
+    "--scheduled-at",
+    help="""ISO 8601 Datetime at which to schedule a status. Must be at least 5
+            minutes in the future.""",
+)
+@click.option(
+    "--scheduled-in",
+    help=f"""Schedule the toot to be posted after a given amount of time,
+             {DURATION_EXAMPLES}. Must be at least 5 minutes.""",
+    callback=validate_duration,
+)
+@click.option(
+    "--content-type", "-t",
+    help="MIME type for the status text (not supported on all instances)",
+)
+@click.option(
+    "--poll-option",
+    help="Possible answer to the poll, can be given multiple times.",
+    multiple=True,
+)
+@click.option(
+    "--poll-expires-in",
+    help=f"Duration that the poll should be open, {DURATION_EXAMPLES}",
+    callback=validate_duration,
+    default="24h",
+)
+@click.option(
+    "--poll-multiple",
+    help="Allow multiple answers to be selected.",
+    is_flag=True,
+    default=False,
+)
+@click.option(
+    "--poll-hide-totals",
+    help="Hide vote counts until the poll ends.",
+    is_flag=True,
+    default=False,
+)
+@json_option
+@pass_context
+def post(
+    ctx: Context,
+    text: Optional[str],
+    media: Tuple[str],
+    descriptions: Tuple[str],
+    thumbnails: Tuple[str],
+    visibility: str,
+    sensitive: bool,
+    spoiler_text: Optional[str],
+    reply_to: Optional[str],
+    language: Optional[str],
+    editor: Optional[str],
+    scheduled_at: Optional[str],
+    scheduled_in: Optional[int],
+    content_type: Optional[str],
+    poll_option: Tuple[str],
+    poll_expires_in: int,
+    poll_multiple: bool,
+    poll_hide_totals: bool,
+    json: bool
+):
+    """Post a new status"""
+    if len(media) > 4:
+        raise click.ClickException("Cannot attach more than 4 files.")
+
+    media_ids = _upload_media(ctx.app, ctx.user, media, descriptions, thumbnails)
+    status_text = _get_status_text(text, editor, media)
+    scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)
+
+    if not status_text and not media_ids:
+        raise click.ClickException("You must specify either text or media to post.")
+
+    response = api.post_status(
+        ctx.app,
+        ctx.user,
+        status_text,
+        visibility=visibility,
+        media_ids=media_ids,
+        sensitive=sensitive,
+        spoiler_text=spoiler_text,
+        in_reply_to_id=reply_to,
+        language=language,
+        scheduled_at=scheduled_at,
+        content_type=content_type,
+        poll_options=poll_option,
+        poll_expires_in=poll_expires_in,
+        poll_multiple=poll_multiple,
+        poll_hide_totals=poll_hide_totals,
+    )
+
+    if json:
+        click.echo(response.text)
+    else:
+        status = response.json()
+        if "scheduled_at" in status:
+            scheduled_at = parse_datetime(status["scheduled_at"])
+            scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z")
+            click.echo(f"Toot scheduled for: {scheduled_at}")
+        else:
+            click.echo(f"Toot posted: {status['url']}")
+
+    delete_tmp_status_file()
+
+
+@cli.command()
+@click.argument("file", type=click.File(mode="rb"))
+@click.option(
+    "--description", "-d",
+    help="Plain-text description of the media for accessibility purposes"
+)
+@json_option
+@pass_context
+def upload(
+    ctx: Context,
+    file: BinaryIO,
+    description: Optional[str],
+    json: bool,
+):
+    """Upload an image or video file
+
+    This is probably not very useful, see `toot post --media` instead.
+    """
+    response = _do_upload(ctx.app, ctx.user, file, description, None)
+    if json:
+        click.echo(response.text)
+    else:
+        media = from_dict(MediaAttachment, response.json())
+        click.echo()
+        click.echo(f"Successfully uploaded media ID {media.id}, type '{media.type}'")
+        click.echo(f"URL: {media.url}")
+        click.echo(f"Preview URL: {media.preview_url}")
+
+
+def _get_status_text(text, editor, media):
+    isatty = sys.stdin.isatty()
+
+    if not text and not isatty:
+        text = sys.stdin.read().rstrip()
+
+    if isatty:
+        if editor:
+            text = editor_input(editor, text)
+        elif not text and not media:
+            click.echo(f"Write or paste your toot. Press {EOF_KEY} to post it.")
+            text = multiline_input()
+
+    return text
+
+
+def _get_scheduled_at(scheduled_at, scheduled_in):
+    if scheduled_at:
+        return scheduled_at
+
+    if scheduled_in:
+        scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in)
+        return scheduled_at.replace(microsecond=0).isoformat()
+
+    return None
+
+
+def _upload_media(app, user, media, descriptions, thumbnails):
+    # Match media to corresponding descriptions and thumbnail
+    media = media or []
+    descriptions = descriptions or []
+    thumbnails = thumbnails or []
+    uploaded_media = []
+
+    for idx, file in enumerate(media):
+        description = descriptions[idx].strip() if idx < len(descriptions) else None
+        thumbnail = thumbnails[idx] if idx < len(thumbnails) else None
+        result = _do_upload(app, user, file, description, thumbnail).json()
+        uploaded_media.append(result)
+
+    _wait_until_all_processed(app, user, uploaded_media)
+
+    return [m["id"] for m in uploaded_media]
+
+
+def _do_upload(app, user, file, description, thumbnail):
+    return api.upload_media(app, user, file, description=description, thumbnail=thumbnail)
+
+
+def _wait_until_all_processed(app, user, uploaded_media):
+    """
+    Media is uploaded asynchronously, and cannot be attached until the server
+    has finished processing it. This function waits for that to happen.
+
+    Once media is processed, it will have the URL populated.
+    """
+    if all(m["url"] for m in uploaded_media):
+        return
+
+    # Timeout after waiting 1 minute
+    start_time = time()
+    timeout = 60
+
+    click.echo("Waiting for media to finish processing...")
+    for media in uploaded_media:
+        _wait_until_processed(app, user, media, start_time, timeout)
+
+
+def _wait_until_processed(app, user, media, start_time, timeout):
+    if media["url"]:
+        return
+
+    media = api.get_media(app, user, media["id"])
+    while not media["url"]:
+        sleep(1)
+        if time() > start_time + timeout:
+            raise click.ClickException(f"Media not processed by server after {timeout} seconds. Aborting.")
+        media = api.get_media(app, user, media["id"])
diff --git a/toot/cli/read.py b/toot/cli/read.py
new file mode 100644
index 00000000..341fb8e4
--- /dev/null
+++ b/toot/cli/read.py
@@ -0,0 +1,114 @@
+import click
+import json as pyjson
+
+from itertools import chain
+from typing import Optional
+
+from toot import api
+from toot.cli.validators import validate_instance
+from toot.entities import Instance, Status, from_dict, Account
+from toot.exceptions import ApiError, ConsoleError
+from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline
+from toot.cli import cli, json_option, pass_context, Context
+
+
+@cli.command()
+@json_option
+@pass_context
+def whoami(ctx: Context, json: bool):
+    """Display logged in user details"""
+    response = api.verify_credentials(ctx.app, ctx.user)
+
+    if json:
+        click.echo(response.text)
+    else:
+        account = from_dict(Account, response.json())
+        print_account(account)
+
+
+@cli.command()
+@click.argument("account")
+@json_option
+@pass_context
+def whois(ctx: Context, account: str, json: bool):
+    """Display account details"""
+    account_dict = api.find_account(ctx.app, ctx.user, account)
+
+    # Here it's not possible to avoid parsing json since it's needed to find the account.
+    if json:
+        click.echo(pyjson.dumps(account_dict))
+    else:
+        account_obj = from_dict(Account, account_dict)
+        print_account(account_obj)
+
+
+@cli.command()
+@click.argument("instance_url", required=False, callback=validate_instance)
+@json_option
+@pass_context
+def instance(ctx: Context, instance_url: Optional[str], json: bool):
+    """Display instance details"""
+    default_url = ctx.app.base_url if ctx.app else None
+    base_url = instance_url or default_url
+
+    if not base_url:
+        raise ConsoleError("Please specify an instance.")
+
+    try:
+        response = api.get_instance(base_url)
+    except ApiError:
+        raise ConsoleError(
+            f"Instance not found at {base_url}.\n" +
+            "The given domain probably does not host a Mastodon instance."
+        )
+
+    if json:
+        click.echo(response.text)
+    else:
+        instance = from_dict(Instance, response.json())
+        print_instance(instance)
+
+
+@cli.command()
+@click.argument("query")
+@click.option("-r", "--resolve", is_flag=True, help="Resolve non-local accounts")
+@json_option
+@pass_context
+def search(ctx: Context, query: str, resolve: bool, json: bool):
+    """Search for users or hashtags"""
+    response = api.search(ctx.app, ctx.user, query, resolve)
+    if json:
+        click.echo(response.text)
+    else:
+        print_search_results(response.json())
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def status(ctx: Context, status_id: str, json: bool):
+    """Show a single status"""
+    response = api.fetch_status(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(response.text)
+    else:
+        status = from_dict(Status, response.json())
+        print_status(status)
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def thread(ctx: Context, status_id: str, json: bool):
+    """Show thread for a toot."""
+    context_response = api.context(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(context_response.text)
+    else:
+        toot = api.fetch_status(ctx.app, ctx.user, status_id).json()
+        context = context_response.json()
+
+        statuses = chain(context["ancestors"], [toot], context["descendants"])
+        print_timeline(from_dict(Status, s) for s in statuses)
diff --git a/toot/cli/statuses.py b/toot/cli/statuses.py
new file mode 100644
index 00000000..0d088b20
--- /dev/null
+++ b/toot/cli/statuses.py
@@ -0,0 +1,148 @@
+import click
+
+from toot import api
+from toot.cli import cli, json_option, Context, pass_context
+from toot.cli import VISIBILITY_CHOICES
+from toot.output import print_table
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def delete(ctx: Context, status_id: str, json: bool):
+    """Delete a status"""
+    response = api.delete_status(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Status deleted", fg="green")
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def favourite(ctx: Context, status_id: str, json: bool):
+    """Favourite a status"""
+    response = api.favourite(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Status favourited", fg="green")
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def unfavourite(ctx: Context, status_id: str, json: bool):
+    """Unfavourite a status"""
+    response = api.unfavourite(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Status unfavourited", fg="green")
+
+
+@cli.command()
+@click.argument("status_id")
+@click.option(
+    "--visibility", "-v",
+    help="Post visibility",
+    type=click.Choice(VISIBILITY_CHOICES),
+    default="public",
+)
+@json_option
+@pass_context
+def reblog(ctx: Context, status_id: str, visibility: str, json: bool):
+    """Reblog (boost) a status"""
+    response = api.reblog(ctx.app, ctx.user, status_id, visibility=visibility)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Status reblogged", fg="green")
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def unreblog(ctx: Context, status_id: str, json: bool):
+    """Unreblog (unboost) a status"""
+    response = api.unreblog(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Status unreblogged", fg="green")
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def pin(ctx: Context, status_id: str, json: bool):
+    """Pin a status"""
+    response = api.pin(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Status pinned", fg="green")
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def unpin(ctx: Context, status_id: str, json: bool):
+    """Unpin a status"""
+    response = api.unpin(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Status unpinned", fg="green")
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def bookmark(ctx: Context, status_id: str, json: bool):
+    """Bookmark a status"""
+    response = api.bookmark(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Status bookmarked", fg="green")
+
+
+@cli.command()
+@click.argument("status_id")
+@json_option
+@pass_context
+def unbookmark(ctx: Context, status_id: str, json: bool):
+    """Unbookmark a status"""
+    response = api.unbookmark(ctx.app, ctx.user, status_id)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho("✓ Status unbookmarked", fg="green")
+
+
+@cli.command(name="reblogged_by")
+@click.argument("status_id")
+@json_option
+@pass_context
+def reblogged_by(ctx: Context, status_id: str, json: bool):
+    """Show accounts that reblogged a status"""
+    response = api.reblogged_by(ctx.app, ctx.user, status_id)
+
+    if json:
+        click.echo(response.text)
+    else:
+        rows = [[a["acct"], a["display_name"]] for a in response.json()]
+        if rows:
+            headers = ["Account", "Display name"]
+            print_table(headers, rows)
+        else:
+            click.echo("This status is not reblogged by anyone")
diff --git a/toot/cli/tags.py b/toot/cli/tags.py
new file mode 100644
index 00000000..b79fdf00
--- /dev/null
+++ b/toot/cli/tags.py
@@ -0,0 +1,163 @@
+import click
+import json as pyjson
+
+from toot import api
+from toot.cli import cli, pass_context, json_option, Context
+from toot.entities import Tag, from_dict
+from toot.output import print_tag_list, print_warning
+
+
+@cli.group()
+def tags():
+    """List, follow, and unfollow tags"""
+
+
+@tags.command()
+@click.argument("tag")
+@json_option
+@pass_context
+def info(ctx: Context, tag, json: bool):
+    """Show a hashtag and its associated information"""
+    tag = api.find_tag(ctx.app, ctx.user, tag)
+
+    if not tag:
+        raise click.ClickException("Tag not found")
+
+    if json:
+        click.echo(pyjson.dumps(tag))
+    else:
+        tag = from_dict(Tag, tag)
+        click.secho(f"#{tag.name}", fg="yellow")
+        click.secho(tag.url, italic=True)
+        if tag.following:
+            click.echo("Followed")
+        else:
+            click.echo("Not followed")
+
+
+@tags.command()
+@json_option
+@pass_context
+def followed(ctx: Context, json: bool):
+    """List followed tags"""
+    tags = api.followed_tags(ctx.app, ctx.user)
+    if json:
+        click.echo(pyjson.dumps(tags))
+    else:
+        if tags:
+            print_tag_list(tags)
+        else:
+            click.echo("You're not following any hashtags")
+
+
+@tags.command()
+@click.argument("tag")
+@json_option
+@pass_context
+def follow(ctx: Context, tag: str, json: bool):
+    """Follow a hashtag"""
+    tag = tag.lstrip("#")
+    response = api.follow_tag(ctx.app, ctx.user, tag)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ You are now following #{tag}", fg="green")
+
+
+@tags.command()
+@click.argument("tag")
+@json_option
+@pass_context
+def unfollow(ctx: Context, tag: str, json: bool):
+    """Unfollow a hashtag"""
+    tag = tag.lstrip("#")
+    response = api.unfollow_tag(ctx.app, ctx.user, tag)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ You are no longer following #{tag}", fg="green")
+
+
+@tags.command()
+@json_option
+@pass_context
+def featured(ctx: Context, json: bool):
+    """List hashtags featured on your profile."""
+    response = api.featured_tags(ctx.app, ctx.user)
+    if json:
+        click.echo(response.text)
+    else:
+        tags = response.json()
+        if tags:
+            print_tag_list(tags)
+        else:
+            click.echo("You don't have any featured hashtags")
+
+
+@tags.command()
+@click.argument("tag")
+@json_option
+@pass_context
+def feature(ctx: Context, tag: str, json: bool):
+    """Feature a hashtag on your profile"""
+    tag = tag.lstrip("#")
+    response = api.feature_tag(ctx.app, ctx.user, tag)
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ Tag #{tag} is now featured", fg="green")
+
+
+@tags.command()
+@click.argument("tag")
+@json_option
+@pass_context
+def unfeature(ctx: Context, tag: str, json: bool):
+    """Unfollow a hashtag
+
+    TAG can either be a tag name like "#foo" or "foo" or a tag ID.
+    """
+    featured_tag = api.find_featured_tag(ctx.app, ctx.user, tag)
+
+    # TODO: should this be idempotent?
+    if not featured_tag:
+        raise click.ClickException(f"Tag {tag} is not featured")
+
+    response = api.unfeature_tag(ctx.app, ctx.user, featured_tag["id"])
+    if json:
+        click.echo(response.text)
+    else:
+        click.secho(f"✓ Tag #{featured_tag['name']} is no longer featured", fg="green")
+
+
+# -- Deprecated commands -------------------------------------------------------
+
+@cli.command(name="tags_followed", hidden=True)
+@pass_context
+def tags_followed(ctx: Context):
+    """List hashtags you follow"""
+    print_warning("`toot tags_followed` is deprecated in favour of `toot tags followed`")
+    response = api.followed_tags(ctx.app, ctx.user)
+    print_tag_list(response)
+
+
+@cli.command(name="tags_follow", hidden=True)
+@click.argument("tag")
+@pass_context
+def tags_follow(ctx: Context, tag: str):
+    """Follow a hashtag"""
+    print_warning("`toot tags_follow` is deprecated in favour of `toot tags follow`")
+    tag = tag.lstrip("#")
+    api.follow_tag(ctx.app, ctx.user, tag)
+    click.secho(f"✓ You are now following #{tag}", fg="green")
+
+
+@cli.command(name="tags_unfollow", hidden=True)
+@click.argument("tag")
+@pass_context
+def tags_unfollow(ctx: Context, tag: str):
+    """Unfollow a hashtag"""
+    print_warning("`toot tags_unfollow` is deprecated in favour of `toot tags unfollow`")
+    tag = tag.lstrip("#")
+    api.unfollow_tag(ctx.app, ctx.user, tag)
+    click.secho(f"✓ You are no longer following #{tag}", fg="green")
diff --git a/toot/cli/timelines.py b/toot/cli/timelines.py
new file mode 100644
index 00000000..fc2be24c
--- /dev/null
+++ b/toot/cli/timelines.py
@@ -0,0 +1,180 @@
+import sys
+import click
+
+from toot import api
+from toot.cli import cli, pass_context, Context
+from typing import Optional
+from toot.cli.validators import validate_instance
+
+from toot.entities import Notification, Status, from_dict
+from toot.output import print_notifications, print_timeline
+
+
+@cli.command()
+@click.option(
+    "--instance", "-i",
+    callback=validate_instance,
+    help="""Domain or base URL of the instance from which to read,
+            e.g. 'mastodon.social' or 'https://mastodon.social'""",
+)
+@click.option("--account", "-a", help="Show account timeline")
+@click.option("--list", help="Show list timeline")
+@click.option("--tag", "-t", help="Show hashtag timeline")
+@click.option("--public", "-p", is_flag=True, help="Show public timeline")
+@click.option(
+    "--local", "-l", is_flag=True,
+    help="Show only statuses from the local instance (public and tag timelines only)"
+)
+@click.option(
+    "--reverse", "-r", is_flag=True,
+    help="Reverse the order of the shown timeline (new posts at the bottom)"
+)
+@click.option(
+    "--once", "-1", is_flag=True,
+    help="Only show the first <count> toots, do not prompt to continue"
+)
+@click.option(
+    "--count", "-c", type=int, default=10,
+    help="Number of posts per page (max 20)"
+)
+@pass_context
+def timeline(
+    ctx: Context,
+    instance: Optional[str],
+    account: Optional[str],
+    list: Optional[str],
+    tag: Optional[str],
+    public: bool,
+    local: bool,
+    reverse: bool,
+    once: bool,
+    count: int,
+):
+    """Show recent items in a timeline
+
+    By default shows the home timeline.
+    """
+    if len([arg for arg in [tag, list, public, account] if arg]) > 1:
+        raise click.ClickException("Only one of --public, --tag, --account, or --list can be used at one time.")
+
+    if local and not (public or tag):
+        raise click.ClickException("The --local option is only valid alongside --public or --tag.")
+
+    if instance and not (public or tag):
+        raise click.ClickException("The --instance option is only valid alongside --public or --tag.")
+
+    list_id = _get_list_id(ctx, list)
+
+    """Show recent statuses in a timeline"""
+    generator = api.get_timeline_generator(
+        ctx.app,
+        ctx.user,
+        base_url=instance,
+        account=account,
+        list_id=list_id,
+        tag=tag,
+        public=public,
+        local=local,
+        limit=count,
+    )
+
+    _show_timeline(generator, reverse, once)
+
+
+@cli.command()
+@click.option(
+    "--reverse", "-r", is_flag=True,
+    help="Reverse the order of the shown timeline (new posts at the bottom)"
+)
+@click.option(
+    "--once", "-1", is_flag=True,
+    help="Only show the first <count> toots, do not prompt to continue"
+)
+@click.option(
+    "--count", "-c", type=int, default=10,
+    help="Number of posts per page (max 20)"
+)
+@pass_context
+def bookmarks(
+    ctx: Context,
+    reverse: bool,
+    once: bool,
+    count: int,
+):
+    """Show recent statuses in a timeline"""
+    generator = api.bookmark_timeline_generator(ctx.app, ctx.user, limit=count)
+    _show_timeline(generator, reverse, once)
+
+
+@cli.command()
+@click.option("--clear", help="Dismiss all notifications and exit")
+@click.option(
+    "--reverse", "-r", is_flag=True,
+    help="Reverse the order of the shown notifications (newest on top)"
+)
+@click.option(
+    "--mentions", "-m", is_flag=True,
+    help="Show only mentions"
+)
+@pass_context
+def notifications(
+    ctx: Context,
+    clear: bool,
+    reverse: bool,
+    mentions: int,
+):
+    """Show notifications"""
+    if clear:
+        api.clear_notifications(ctx.app, ctx.user)
+        click.secho("✓ Notifications cleared", fg="green")
+        return
+
+    exclude = []
+    if mentions:
+        # Filter everything except mentions
+        # https://docs.joinmastodon.org/methods/notifications/
+        exclude = ["follow", "favourite", "reblog", "poll", "follow_request"]
+
+    notifications = api.get_notifications(ctx.app, ctx.user, exclude_types=exclude)
+
+    if not notifications:
+        click.echo("You have no notifications")
+        return
+
+    if reverse:
+        notifications = reversed(notifications)
+
+    notifications = [from_dict(Notification, n) for n in notifications]
+    print_notifications(notifications)
+
+
+def _show_timeline(generator, reverse, once):
+    while True:
+        try:
+            items = next(generator)
+        except StopIteration:
+            click.echo("That's all folks.")
+            return
+
+        if reverse:
+            items = reversed(items)
+
+        statuses = [from_dict(Status, item) for item in items]
+        print_timeline(statuses)
+
+        if once or not sys.stdout.isatty():
+            break
+
+        char = input("\nContinue? [Y/n] ")
+        if char.lower() == "n":
+            break
+
+
+def _get_list_id(ctx: Context, value: Optional[str]) -> Optional[str]:
+    if not value:
+        return None
+
+    lists = api.get_lists(ctx.app, ctx.user)
+    for list in lists:
+        if list["id"] == value or list["title"] == value:
+            return list["id"]
diff --git a/toot/cli/tui.py b/toot/cli/tui.py
new file mode 100644
index 00000000..1cedd75f
--- /dev/null
+++ b/toot/cli/tui.py
@@ -0,0 +1,44 @@
+import click
+
+from typing import Optional
+from toot.cli import TUI_COLORS, Context, cli, pass_context
+from toot.cli.validators import validate_tui_colors
+from toot.tui.app import TUI, TuiOptions
+
+COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
+
+
+@cli.command()
+@click.option(
+    "-r", "--relative-datetimes",
+    is_flag=True,
+    help="Show relative datetimes in status list"
+)
+@click.option(
+    "-m", "--media-viewer",
+    help="Program to invoke with media URLs to display the media files, such as 'feh'"
+)
+@click.option(
+    "-c", "--colors",
+    callback=validate_tui_colors,
+    help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
+             using --color, and 1 if using --no-color."""
+)
+@pass_context
+def tui(
+    ctx: Context,
+    colors: Optional[int],
+    media_viewer: Optional[str],
+    relative_datetimes: bool,
+):
+    """Launches the toot terminal user interface"""
+    if colors is None:
+        colors = 16 if ctx.color else 1
+
+    options = TuiOptions(
+        colors=colors,
+        media_viewer=media_viewer,
+        relative_datetimes=relative_datetimes,
+    )
+    tui = TUI.create(ctx.app, ctx.user, options)
+    tui.run()
diff --git a/toot/cli/validators.py b/toot/cli/validators.py
new file mode 100644
index 00000000..6b7c8fee
--- /dev/null
+++ b/toot/cli/validators.py
@@ -0,0 +1,75 @@
+import click
+import re
+
+from click import Context
+from typing import Optional
+
+from toot.cli import TUI_COLORS
+
+
+def validate_language(ctx: Context, param: str, value: Optional[str]):
+    if value is None:
+        return None
+
+    value = value.strip().lower()
+    if re.match(r"^[a-z]{2}$", value):
+        return value
+
+    raise click.BadParameter("Language should be a two letter abbreviation.")
+
+
+def validate_duration(ctx: Context, param: str, value: Optional[str]) -> Optional[int]:
+    if value is None:
+        return None
+
+    match = re.match(r"""^
+        (([0-9]+)\s*(days|day|d))?\s*
+        (([0-9]+)\s*(hours|hour|h))?\s*
+        (([0-9]+)\s*(minutes|minute|m))?\s*
+        (([0-9]+)\s*(seconds|second|s))?\s*
+    $""", value, re.X)
+
+    if not match:
+        raise click.BadParameter(f"Invalid duration: {value}")
+
+    days = match.group(2)
+    hours = match.group(5)
+    minutes = match.group(8)
+    seconds = match.group(11)
+
+    days = int(match.group(2) or 0) * 60 * 60 * 24
+    hours = int(match.group(5) or 0) * 60 * 60
+    minutes = int(match.group(8) or 0) * 60
+    seconds = int(match.group(11) or 0)
+
+    duration = days + hours + minutes + seconds
+
+    if duration == 0:
+        raise click.BadParameter("Empty duration")
+
+    return duration
+
+
+def validate_instance(ctx: click.Context, param: str, value: Optional[str]):
+    """
+    Instance can be given either as a base URL or the domain name.
+    Return the base URL.
+    """
+    if not value:
+        return None
+
+    value = value.rstrip("/")
+    return value if value.startswith("http") else f"https://{value}"
+
+
+def validate_tui_colors(ctx, param, value) -> Optional[int]:
+    if value is None:
+        return None
+
+    if value in TUI_COLORS.values():
+        return value
+
+    if value in TUI_COLORS.keys():
+        return TUI_COLORS[value]
+
+    raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}")
diff --git a/toot/commands.py b/toot/commands.py
deleted file mode 100644
index d564cbf9..00000000
--- a/toot/commands.py
+++ /dev/null
@@ -1,669 +0,0 @@
-from itertools import chain
-import json
-import sys
-import platform
-
-from datetime import datetime, timedelta, timezone
-from time import sleep, time
-
-from toot import api, config, __version__
-from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
-from toot.entities import Account, Instance, Notification, Status, from_dict
-from toot.exceptions import ApiError, ConsoleError
-from toot.output import (print_lists, print_out, print_instance, print_account, print_acct_list,
-                         print_search_results, print_status, print_table, print_timeline, print_notifications,
-                         print_tag_list, print_list_accounts, print_user_list)
-from toot.utils import args_get_instance, delete_tmp_status_file, editor_input, multiline_input, EOF_KEY
-from toot.utils.datetime import parse_datetime
-
-
-def get_timeline_generator(app, user, args):
-    if len([arg for arg in [args.tag, args.list, args.public, args.account] if arg]) > 1:
-        raise ConsoleError("Only one of --public, --tag, --account, or --list can be used at one time.")
-
-    if args.local and not (args.public or args.tag):
-        raise ConsoleError("The --local option is only valid alongside --public or --tag.")
-
-    if args.instance and not (args.public or args.tag):
-        raise ConsoleError("The --instance option is only valid alongside --public or --tag.")
-
-    if args.public:
-        if args.instance:
-            return api.anon_public_timeline_generator(args.instance, local=args.local, limit=args.count)
-        else:
-            return api.public_timeline_generator(app, user, local=args.local, limit=args.count)
-    elif args.tag:
-        if args.instance:
-            return api.anon_tag_timeline_generator(args.instance, args.tag, limit=args.count)
-        else:
-            return api.tag_timeline_generator(app, user, args.tag, local=args.local, limit=args.count)
-    elif args.account:
-        return api.account_timeline_generator(app, user, args.account, limit=args.count)
-    elif args.list:
-        return api.timeline_list_generator(app, user, args.list, limit=args.count)
-    else:
-        return api.home_timeline_generator(app, user, limit=args.count)
-
-
-def timeline(app, user, args, generator=None):
-    if not generator:
-        generator = get_timeline_generator(app, user, args)
-
-    while True:
-        try:
-            items = next(generator)
-        except StopIteration:
-            print_out("That's all folks.")
-            return
-
-        if args.reverse:
-            items = reversed(items)
-
-        statuses = [from_dict(Status, item) for item in items]
-        print_timeline(statuses)
-
-        if args.once or not sys.stdout.isatty():
-            break
-
-        char = input("\nContinue? [Y/n] ")
-        if char.lower() == "n":
-            break
-
-
-def status(app, user, args):
-    response = api.fetch_status(app, user, args.status_id)
-    if args.json:
-        print(response.text)
-    else:
-        status = from_dict(Status, response.json())
-        print_status(status)
-
-
-def thread(app, user, args):
-    context_response = api.context(app, user, args.status_id)
-
-    if args.json:
-        print(context_response.text)
-    else:
-        toot = api.fetch_status(app, user, args.status_id).json()
-        context = context_response.json()
-
-        statuses = chain(context["ancestors"], [toot], context["descendants"])
-        print_timeline(from_dict(Status, s) for s in statuses)
-
-
-def post(app, user, args):
-    if args.editor and not sys.stdin.isatty():
-        raise ConsoleError("Cannot run editor if not in tty.")
-
-    if args.media and len(args.media) > 4:
-        raise ConsoleError("Cannot attach more than 4 files.")
-
-    media_ids = _upload_media(app, user, args)
-    status_text = _get_status_text(args.text, args.editor, args.media)
-    scheduled_at = _get_scheduled_at(args.scheduled_at, args.scheduled_in)
-
-    if not status_text and not media_ids:
-        raise ConsoleError("You must specify either text or media to post.")
-
-    response = api.post_status(
-        app, user, status_text,
-        visibility=args.visibility,
-        media_ids=media_ids,
-        sensitive=args.sensitive,
-        spoiler_text=args.spoiler_text,
-        in_reply_to_id=args.reply_to,
-        language=args.language,
-        scheduled_at=scheduled_at,
-        content_type=args.content_type,
-        poll_options=args.poll_option,
-        poll_expires_in=args.poll_expires_in,
-        poll_multiple=args.poll_multiple,
-        poll_hide_totals=args.poll_hide_totals,
-    )
-
-    if args.json:
-        print(response.text)
-    else:
-        status = response.json()
-        if "scheduled_at" in status:
-            scheduled_at = parse_datetime(status["scheduled_at"])
-            scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z")
-            print_out(f"Toot scheduled for: <green>{scheduled_at}</green>")
-        else:
-            print_out(f"Toot posted: <green>{status['url']}")
-
-    delete_tmp_status_file()
-
-
-def _get_status_text(text, editor, media):
-    isatty = sys.stdin.isatty()
-
-    if not text and not isatty:
-        text = sys.stdin.read().rstrip()
-
-    if isatty:
-        if editor:
-            text = editor_input(editor, text)
-        elif not text and not media:
-            print_out("Write or paste your toot. Press <yellow>{}</yellow> to post it.".format(EOF_KEY))
-            text = multiline_input()
-
-    return text
-
-
-def _get_scheduled_at(scheduled_at, scheduled_in):
-    if scheduled_at:
-        return scheduled_at
-
-    if scheduled_in:
-        scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in)
-        return scheduled_at.replace(microsecond=0).isoformat()
-
-    return None
-
-
-def _upload_media(app, user, args):
-    # Match media to corresponding description and thumbnail
-    media = args.media or []
-    descriptions = args.description or []
-    thumbnails = args.thumbnail or []
-    uploaded_media = []
-
-    for idx, file in enumerate(media):
-        description = descriptions[idx].strip() if idx < len(descriptions) else None
-        thumbnail = thumbnails[idx] if idx < len(thumbnails) else None
-        result = _do_upload(app, user, file, description, thumbnail)
-        uploaded_media.append(result)
-
-    _wait_until_all_processed(app, user, uploaded_media)
-
-    return [m["id"] for m in uploaded_media]
-
-
-def _wait_until_all_processed(app, user, uploaded_media):
-    """
-    Media is uploaded asynchronously, and cannot be attached until the server
-    has finished processing it. This function waits for that to happen.
-
-    Once media is processed, it will have the URL populated.
-    """
-    if all(m["url"] for m in uploaded_media):
-        return
-
-    # Timeout after waiting 1 minute
-    start_time = time()
-    timeout = 60
-
-    print_out("<dim>Waiting for media to finish processing...</dim>")
-    for media in uploaded_media:
-        _wait_until_processed(app, user, media, start_time, timeout)
-
-
-def _wait_until_processed(app, user, media, start_time, timeout):
-    if media["url"]:
-        return
-
-    media = api.get_media(app, user, media["id"])
-    while not media["url"]:
-        sleep(1)
-        if time() > start_time + timeout:
-            raise ConsoleError(f"Media not processed by server after {timeout} seconds. Aborting.")
-        media = api.get_media(app, user, media["id"])
-
-
-def delete(app, user, args):
-    response = api.delete_status(app, user, args.status_id)
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Status deleted</green>")
-
-
-def favourite(app, user, args):
-    response = api.favourite(app, user, args.status_id)
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Status favourited</green>")
-
-
-def unfavourite(app, user, args):
-    response = api.unfavourite(app, user, args.status_id)
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Status unfavourited</green>")
-
-
-def reblog(app, user, args):
-    response = api.reblog(app, user, args.status_id, visibility=args.visibility)
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Status reblogged</green>")
-
-
-def unreblog(app, user, args):
-    response = api.unreblog(app, user, args.status_id)
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Status unreblogged</green>")
-
-
-def pin(app, user, args):
-    response = api.pin(app, user, args.status_id)
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Status pinned</green>")
-
-
-def unpin(app, user, args):
-    response = api.unpin(app, user, args.status_id)
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Status unpinned</green>")
-
-
-def bookmark(app, user, args):
-    response = api.bookmark(app, user, args.status_id)
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Status bookmarked</green>")
-
-
-def unbookmark(app, user, args):
-    response = api.unbookmark(app, user, args.status_id)
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Status unbookmarked</green>")
-
-
-def bookmarks(app, user, args):
-    timeline(app, user, args, api.bookmark_timeline_generator(app, user, limit=args.count))
-
-
-def reblogged_by(app, user, args):
-    response = api.reblogged_by(app, user, args.status_id)
-
-    if args.json:
-        print(response.text)
-    else:
-        headers = ["Account", "Display name"]
-        rows = [[a["acct"], a["display_name"]] for a in response.json()]
-        print_table(headers, rows)
-
-
-def auth(app, user, args):
-    config_data = config.load_config()
-
-    if not config_data["users"]:
-        print_out("You are not logged in to any accounts")
-        return
-
-    active_user = config_data["active_user"]
-
-    print_out("Authenticated accounts:")
-    for uid, u in config_data["users"].items():
-        active_label = "ACTIVE" if active_user == uid else ""
-        print_out("* <green>{}</green> <yellow>{}</yellow>".format(uid, active_label))
-
-    path = config.get_config_file_path()
-    print_out("\nAuth tokens are stored in: <blue>{}</blue>".format(path))
-
-
-def env(app, user, args):
-    print_out(f"toot {__version__}")
-    print_out(f"Python {sys.version}")
-    print_out(platform.platform())
-
-
-def update_account(app, user, args):
-    options = [
-        args.avatar,
-        args.bot,
-        args.discoverable,
-        args.display_name,
-        args.header,
-        args.language,
-        args.locked,
-        args.note,
-        args.privacy,
-        args.sensitive,
-    ]
-
-    if all(option is None for option in options):
-        raise ConsoleError("Please specify at least one option to update the account")
-
-    response = api.update_account(
-        app,
-        user,
-        avatar=args.avatar,
-        bot=args.bot,
-        discoverable=args.discoverable,
-        display_name=args.display_name,
-        header=args.header,
-        language=args.language,
-        locked=args.locked,
-        note=args.note,
-        privacy=args.privacy,
-        sensitive=args.sensitive,
-    )
-
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ Account updated</green>")
-
-
-def login_cli(app, user, args):
-    base_url = args_get_instance(args.instance, args.scheme)
-    app = create_app_interactive(base_url)
-    login_interactive(app, args.email)
-
-    print_out()
-    print_out("<green>✓ Successfully logged in.</green>")
-
-
-def login(app, user, args):
-    base_url = args_get_instance(args.instance, args.scheme)
-    app = create_app_interactive(base_url)
-    login_browser_interactive(app)
-
-    print_out()
-    print_out("<green>✓ Successfully logged in.</green>")
-
-
-def logout(app, user, args):
-    user = config.load_user(args.account, throw=True)
-    config.delete_user(user)
-    print_out("<green>✓ User {} logged out</green>".format(config.user_id(user)))
-
-
-def activate(app, user, args):
-    if not args.account:
-        print_out("Specify one of the following user accounts to activate:\n")
-        print_user_list(config.get_user_list())
-        return
-
-    user = config.load_user(args.account, throw=True)
-    config.activate_user(user)
-    print_out("<green>✓ User {} active</green>".format(config.user_id(user)))
-
-
-def upload(app, user, args):
-    response = _do_upload(app, user, args.file, args.description, None)
-
-    msg = "Successfully uploaded media ID <yellow>{}</yellow>, type '<yellow>{}</yellow>'"
-
-    print_out()
-    print_out(msg.format(response['id'], response['type']))
-    print_out("URL: <green>{}</green>".format(response['url']))
-    print_out("Preview URL:  <green>{}</green>".format(response['preview_url']))
-
-
-def search(app, user, args):
-    response = api.search(app, user, args.query, args.resolve)
-    if args.json:
-        print(response.text)
-    else:
-        print_search_results(response.json())
-
-
-def _do_upload(app, user, file, description, thumbnail):
-    print_out("Uploading media: <green>{}</green>".format(file.name))
-    return api.upload_media(app, user, file, description=description, thumbnail=thumbnail)
-
-
-def follow(app, user, args):
-    account = api.find_account(app, user, args.account)
-    response = api.follow(app, user, account["id"])
-    if args.json:
-        print(response.text)
-    else:
-        print_out(f"<green>✓ You are now following {args.account}</green>")
-
-
-def unfollow(app, user, args):
-    account = api.find_account(app, user, args.account)
-    response = api.unfollow(app, user, account["id"])
-    if args.json:
-        print(response.text)
-    else:
-        print_out(f"<green>✓ You are no longer following {args.account}</green>")
-
-
-def following(app, user, args):
-    account = args.account or user.username
-    account = api.find_account(app, user, account)
-    accounts = api.following(app, user, account["id"])
-    if args.json:
-        print(json.dumps(accounts))
-    else:
-        print_acct_list(accounts)
-
-
-def followers(app, user, args):
-    account = args.account or user.username
-    account = api.find_account(app, user, account)
-    accounts = api.followers(app, user, account["id"])
-    if args.json:
-        print(json.dumps(accounts))
-    else:
-        print_acct_list(accounts)
-
-
-def tags_follow(app, user, args):
-    tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:]
-    api.follow_tag(app, user, tn)
-    print_out("<green>✓ You are now following #{}</green>".format(tn))
-
-
-def tags_unfollow(app, user, args):
-    tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:]
-    api.unfollow_tag(app, user, tn)
-    print_out("<green>✓ You are no longer following #{}</green>".format(tn))
-
-
-def tags_followed(app, user, args):
-    response = api.followed_tags(app, user)
-    print_tag_list(response)
-
-
-def lists(app, user, args):
-    lists = api.get_lists(app, user)
-
-    if lists:
-        print_lists(lists)
-    else:
-        print_out("You have no lists defined.")
-
-
-def list_accounts(app, user, args):
-    list_id = _get_list_id(app, user, args)
-    response = api.get_list_accounts(app, user, list_id)
-    print_list_accounts(response)
-
-
-def list_create(app, user, args):
-    api.create_list(app, user, title=args.title, replies_policy=args.replies_policy)
-    print_out(f"<green>✓ List \"{args.title}\" created.</green>")
-
-
-def list_delete(app, user, args):
-    list_id = _get_list_id(app, user, args)
-    api.delete_list(app, user, list_id)
-    print_out(f"<green>✓ List \"{args.title if args.title else args.id}\"</green> <red>deleted.</red>")
-
-
-def list_add(app, user, args):
-    list_id = _get_list_id(app, user, args)
-    account = api.find_account(app, user, args.account)
-
-    try:
-        api.add_accounts_to_list(app, user, list_id, [account['id']])
-    except Exception as ex:
-        # if we failed to add the account, try to give a
-        # more specific error message than "record not found"
-        my_accounts = api.followers(app, user, account['id'])
-        found = False
-        if my_accounts:
-            for my_account in my_accounts:
-                if my_account['id'] == account['id']:
-                    found = True
-                    break
-        if found is False:
-            print_out(f"<red>You must follow @{account['acct']} before adding this account to a list.</red>")
-        else:
-            print_out(f"<red>{ex}</red>")
-        return
-
-    print_out(f"<green>✓ Added account \"{args.account}\"</green>")
-
-
-def list_remove(app, user, args):
-    list_id = _get_list_id(app, user, args)
-    account = api.find_account(app, user, args.account)
-    api.remove_accounts_from_list(app, user, list_id, [account['id']])
-    print_out(f"<green>✓ Removed account \"{args.account}\"</green>")
-
-
-def _get_list_id(app, user, args):
-    list_id = args.id or api.find_list_id(app, user, args.title)
-    if not list_id:
-        raise ConsoleError("List not found")
-    return list_id
-
-
-def mute(app, user, args):
-    account = api.find_account(app, user, args.account)
-    response = api.mute(app, user, account['id'])
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ You have muted {}</green>".format(args.account))
-
-
-def unmute(app, user, args):
-    account = api.find_account(app, user, args.account)
-    response = api.unmute(app, user, account['id'])
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ {} is no longer muted</green>".format(args.account))
-
-
-def muted(app, user, args):
-    response = api.muted(app, user)
-    if args.json:
-        print(json.dumps(response))
-    else:
-        if len(response) > 0:
-            print("Muted accounts:")
-            print_acct_list(response)
-        else:
-            print("No accounts muted")
-
-
-def block(app, user, args):
-    account = api.find_account(app, user, args.account)
-    response = api.block(app, user, account['id'])
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ You are now blocking {}</green>".format(args.account))
-
-
-def unblock(app, user, args):
-    account = api.find_account(app, user, args.account)
-    response = api.unblock(app, user, account['id'])
-    if args.json:
-        print(response.text)
-    else:
-        print_out("<green>✓ {} is no longer blocked</green>".format(args.account))
-
-
-def blocked(app, user, args):
-    response = api.blocked(app, user)
-    if args.json:
-        print(json.dumps(response))
-    else:
-        if len(response) > 0:
-            print("Blocked accounts:")
-            print_acct_list(response)
-        else:
-            print("No accounts blocked")
-
-
-def whoami(app, user, args):
-    response = api.verify_credentials(app, user)
-    if args.json:
-        print(response.text)
-    else:
-        account = from_dict(Account, response.json())
-        print_account(account)
-
-
-def whois(app, user, args):
-    account = api.find_account(app, user, args.account)
-    # Here it's not possible to avoid parsing json since it's needed to find the account.
-    if args.json:
-        print(json.dumps(account))
-    else:
-        account = from_dict(Account, account)
-        print_account(account)
-
-
-def instance(app, user, args):
-    default = app.base_url if app else None
-    base_url = args_get_instance(args.instance, args.scheme, default)
-
-    if not base_url:
-        raise ConsoleError("Please specify an instance.")
-
-    try:
-        response = api.get_instance(base_url)
-    except ApiError:
-        raise ConsoleError(
-            f"Instance not found at {base_url}.\n"
-            "The given domain probably does not host a Mastodon instance."
-        )
-
-    if args.json:
-        print(response.text)
-    else:
-        instance = from_dict(Instance, response.json())
-        print_instance(instance)
-
-
-def notifications(app, user, args):
-    if args.clear:
-        api.clear_notifications(app, user)
-        print_out("<green>Cleared notifications</green>")
-        return
-
-    exclude = []
-    if args.mentions:
-        # Filter everything except mentions
-        # https://docs.joinmastodon.org/methods/notifications/
-        exclude = ["follow", "favourite", "reblog", "poll", "follow_request"]
-    notifications = api.get_notifications(app, user, exclude_types=exclude)
-    if not notifications:
-        print_out("<yellow>No notification</yellow>")
-        return
-
-    if args.reverse:
-        notifications = reversed(notifications)
-
-    notifications = [from_dict(Notification, n) for n in notifications]
-    print_notifications(notifications)
-
-
-def tui(app, user, args):
-    from .tui.app import TUI
-    TUI.create(app, user, args).run()
diff --git a/toot/config.py b/toot/config.py
index 077e098f..21861e64 100644
--- a/toot/config.py
+++ b/toot/config.py
@@ -3,10 +3,10 @@
 
 from functools import wraps
 from os.path import dirname, join
+from typing import Optional
 
 from toot import User, App, get_config_dir
 from toot.exceptions import ConsoleError
-from toot.output import print_out
 
 
 TOOT_CONFIG_FILE_NAME = "config.json"
@@ -29,8 +29,6 @@ def make_config(path):
         "active_user": None,
     }
 
-    print_out("Creating config file at <blue>{}</blue>".format(path))
-
     # Ensure dir exists
     os.makedirs(dirname(path), exist_ok=True)
 
@@ -41,6 +39,10 @@ def make_config(path):
 
 
 def load_config():
+    # Just to prevent accidentally running tests on production
+    if os.environ.get("TOOT_TESTING"):
+        raise Exception("Tests should not access the config file!")
+
     path = get_config_file_path()
 
     if not os.path.exists(path):
@@ -85,7 +87,7 @@ def get_user_app(user_id):
     return extract_user_app(load_config(), user_id)
 
 
-def load_app(instance):
+def load_app(instance: str) -> Optional[App]:
     config = load_config()
     if instance in config['apps']:
         return App(**config['apps'][instance])
diff --git a/toot/console.py b/toot/console.py
deleted file mode 100644
index 41583c12..00000000
--- a/toot/console.py
+++ /dev/null
@@ -1,966 +0,0 @@
-import logging
-import os
-import re
-import shutil
-import sys
-
-from argparse import ArgumentParser, FileType, ArgumentTypeError, Action
-from collections import namedtuple
-from itertools import chain
-from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__, settings
-from toot.exceptions import ApiError, ConsoleError
-from toot.output import print_out, print_err
-from toot.settings import get_setting
-
-VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
-VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
-
-PRIVACY_CHOICES = ["public", "unlisted", "private"]
-PRIVACY_CHOICES_STR = ", ".join(f"'{v}'" for v in PRIVACY_CHOICES)
-
-
-class BooleanOptionalAction(Action):
-    """
-    Backported from argparse. This action is available since Python 3.9.
-    https://github.com/python/cpython/blob/3.11/Lib/argparse.py
-    """
-    def __init__(self,
-                 option_strings,
-                 dest,
-                 default=None,
-                 type=None,
-                 choices=None,
-                 required=False,
-                 help=None,
-                 metavar=None):
-
-        _option_strings = []
-        for option_string in option_strings:
-            _option_strings.append(option_string)
-
-            if option_string.startswith('--'):
-                option_string = '--no-' + option_string[2:]
-                _option_strings.append(option_string)
-
-        super().__init__(
-            option_strings=_option_strings,
-            dest=dest,
-            nargs=0,
-            default=default,
-            type=type,
-            choices=choices,
-            required=required,
-            help=help,
-            metavar=metavar)
-
-    def __call__(self, parser, namespace, values, option_string=None):
-        if option_string in self.option_strings:
-            setattr(namespace, self.dest, not option_string.startswith('--no-'))
-
-    def format_usage(self):
-        return ' | '.join(self.option_strings)
-
-
-def get_default_visibility():
-    return os.getenv("TOOT_POST_VISIBILITY", "public")
-
-
-def language(value):
-    """Validates the language parameter"""
-    if len(value) != 2:
-        raise ArgumentTypeError(
-            "Invalid language. Expected a 2 letter abbreviation according to "
-            "the ISO 639-1 standard."
-        )
-
-    return value
-
-
-def visibility(value):
-    """Validates the visibility parameter"""
-    if value not in VISIBILITY_CHOICES:
-        raise ValueError("Invalid visibility value")
-
-    return value
-
-
-def privacy(value):
-    """Validates the privacy parameter"""
-    if value not in PRIVACY_CHOICES:
-        raise ValueError(f"Invalid privacy value. Expected one of {PRIVACY_CHOICES_STR}.")
-
-    return value
-
-
-def timeline_count(value):
-    n = int(value)
-    if not 0 < n <= 20:
-        raise ArgumentTypeError("Number of toots should be between 1 and 20.")
-    return n
-
-
-DURATION_UNITS = {
-    "m": 60,
-    "h": 60 * 60,
-    "d": 60 * 60 * 24,
-}
-
-
-DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
-seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
-
-
-def duration(value: str):
-    match = re.match(r"""^
-        (([0-9]+)\s*(days|day|d))?\s*
-        (([0-9]+)\s*(hours|hour|h))?\s*
-        (([0-9]+)\s*(minutes|minute|m))?\s*
-        (([0-9]+)\s*(seconds|second|s))?\s*
-    $""", value, re.X)
-
-    if not match:
-        raise ArgumentTypeError(f"Invalid duration: {value}")
-
-    days = match.group(2)
-    hours = match.group(5)
-    minutes = match.group(8)
-    seconds = match.group(11)
-
-    days = int(match.group(2) or 0) * 60 * 60 * 24
-    hours = int(match.group(5) or 0) * 60 * 60
-    minutes = int(match.group(8) or 0) * 60
-    seconds = int(match.group(11) or 0)
-
-    duration = days + hours + minutes + seconds
-
-    if duration == 0:
-        raise ArgumentTypeError("Empty duration")
-
-    return duration
-
-
-def editor(value):
-    if not value:
-        raise ArgumentTypeError(
-            "Editor not specified in --editor option and $EDITOR environment "
-            "variable not set."
-        )
-
-    # Check editor executable exists
-    exe = shutil.which(value)
-    if not exe:
-        raise ArgumentTypeError("Editor `{}` not found".format(value))
-
-    return exe
-
-
-Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"])
-
-
-# Arguments added to every command
-common_args = [
-    (["--no-color"], {
-        "help": "don't use ANSI colors in output",
-        "action": 'store_true',
-        "default": False,
-    }),
-    (["--quiet"], {
-        "help": "don't write to stdout on success",
-        "action": 'store_true',
-        "default": False,
-    }),
-    (["--debug"], {
-        "help": "show debug log in console",
-        "action": 'store_true',
-        "default": False,
-    }),
-    (["--verbose"], {
-        "help": "show extra detail in debug log; used with --debug",
-        "action": 'store_true',
-        "default": False,
-    }),
-]
-
-# Arguments added to commands which require authentication
-common_auth_args = [
-    (["-u", "--using"], {
-        "help": "the account to use, overrides active account",
-    }),
-]
-
-account_arg = (["account"], {
-    "help": "account name, e.g. 'Gargron@mastodon.social'",
-})
-
-optional_account_arg = (["account"], {
-    "nargs": "?",
-    "help": "account name, e.g. 'Gargron@mastodon.social'",
-})
-
-instance_arg = (["-i", "--instance"], {
-    "type": str,
-    "help": 'mastodon instance to log into e.g. "mastodon.social"',
-})
-
-email_arg = (["-e", "--email"], {
-    "type": str,
-    "help": 'email address to log in with',
-})
-
-scheme_arg = (["--disable-https"], {
-    "help": "disable HTTPS and use insecure HTTP",
-    "dest": "scheme",
-    "default": "https",
-    "action": "store_const",
-    "const": "http",
-})
-
-status_id_arg = (["status_id"], {
-    "help": "ID of the status",
-    "type": str,
-})
-
-visibility_arg = (["-v", "--visibility"], {
-    "type": visibility,
-    "default": get_default_visibility(),
-    "help": f"Post visibility. One of: {VISIBILITY_CHOICES_STR}. Defaults to "
-            f"'{get_default_visibility()}' which can be overridden by setting "
-            "the TOOT_POST_VISIBILITY environment variable",
-})
-
-tag_arg = (["tag_name"], {
-    "type": str,
-    "help": "tag name, e.g. Caturday, or \"#Caturday\"",
-})
-
-json_arg = (["--json"], {
-    "action": "store_true",
-    "default": False,
-    "help": "print json instead of plaintext",
-})
-
-# Arguments for selecting a timeline (see `toot.commands.get_timeline_generator`)
-common_timeline_args = [
-    (["-p", "--public"], {
-        "action": "store_true",
-        "default": False,
-        "help": "show public timeline (does not require auth)",
-    }),
-    (["-t", "--tag"], {
-        "type": str,
-        "help": "show hashtag timeline (does not require auth)",
-    }),
-    (["-a", "--account"], {
-        "type": str,
-        "help": "show timeline for the given account",
-    }),
-    (["-l", "--local"], {
-        "action": "store_true",
-        "default": False,
-        "help": "show only statuses from local instance (public and tag timelines only)",
-    }),
-    (["-i", "--instance"], {
-        "type": str,
-        "help": "mastodon instance from which to read (public and tag timelines only)",
-    }),
-    (["--list"], {
-        "type": str,
-        "help": "show timeline for given list.",
-    }),
-]
-
-timeline_and_bookmark_args = [
-    (["-c", "--count"], {
-        "type": timeline_count,
-        "help": "number of toots to show per page (1-20, default 10).",
-        "default": 10,
-    }),
-    (["-r", "--reverse"], {
-        "action": "store_true",
-        "default": False,
-        "help": "Reverse the order of the shown timeline (to new posts at the bottom)",
-    }),
-    (["-1", "--once"], {
-        "action": "store_true",
-        "default": False,
-        "help": "Only show the first <count> toots, do not prompt to continue.",
-    }),
-]
-
-timeline_args = common_timeline_args + timeline_and_bookmark_args
-
-AUTH_COMMANDS = [
-    Command(
-        name="login",
-        description="Log into a mastodon instance using your browser (recommended)",
-        arguments=[instance_arg, scheme_arg],
-        require_auth=False,
-    ),
-    Command(
-        name="login_cli",
-        description="Log in from the console, does NOT support two factor authentication",
-        arguments=[instance_arg, email_arg, scheme_arg],
-        require_auth=False,
-    ),
-    Command(
-        name="activate",
-        description="Switch between logged in accounts.",
-        arguments=[optional_account_arg],
-        require_auth=False,
-    ),
-    Command(
-        name="logout",
-        description="Log out, delete stored access keys",
-        arguments=[account_arg],
-        require_auth=False,
-    ),
-    Command(
-        name="auth",
-        description="Show logged in accounts and instances",
-        arguments=[],
-        require_auth=False,
-    ),
-    Command(
-        name="env",
-        description="Print environment information for inclusion in bug reports.",
-        arguments=[],
-        require_auth=False,
-    ),
-    Command(
-        name="update_account",
-        description="Update your account details",
-        arguments=[
-            (["--display-name"], {
-                "type": str,
-                "help": "The display name to use for the profile.",
-            }),
-            (["--note"], {
-                "type": str,
-                "help": "The account bio.",
-            }),
-            (["--avatar"], {
-                "type": FileType("rb"),
-                "help": "Path to the avatar image to set.",
-            }),
-            (["--header"], {
-                "type": FileType("rb"),
-                "help": "Path to the header image to set.",
-            }),
-            (["--bot"], {
-                "action": BooleanOptionalAction,
-                "help": "Whether the account has a bot flag.",
-            }),
-            (["--discoverable"], {
-                "action": BooleanOptionalAction,
-                "help": "Whether the account should be shown in the profile directory.",
-            }),
-            (["--locked"], {
-                "action": BooleanOptionalAction,
-                "help": "Whether manual approval of follow requests is required.",
-            }),
-            (["--privacy"], {
-                "type": privacy,
-                "help": f"Default post privacy for authored statuses. One of: {PRIVACY_CHOICES_STR}."
-            }),
-            (["--sensitive"], {
-                "action": BooleanOptionalAction,
-                "help": "Whether to mark authored statuses as sensitive by default."
-            }),
-            (["--language"], {
-                "type": language,
-                "help": "Default language to use for authored statuses (ISO 639-1)."
-            }),
-            json_arg,
-        ],
-        require_auth=True,
-    ),
-]
-
-TUI_COMMANDS = [
-    Command(
-        name="tui",
-        description="Launches the toot terminal user interface",
-        arguments=[
-            (["--relative-datetimes"], {
-                "action": "store_true",
-                "default": False,
-                "help": "Show relative datetimes in status list.",
-            }),
-        ],
-        require_auth=True,
-    ),
-]
-
-
-READ_COMMANDS = [
-    Command(
-        name="whoami",
-        description="Display logged in user details",
-        arguments=[json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="whois",
-        description="Display account details",
-        arguments=[
-            (["account"], {
-                "help": "account name or numeric ID"
-            }),
-            json_arg,
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="notifications",
-        description="Notifications for logged in user",
-        arguments=[
-            (["--clear"], {
-                "help": "delete all notifications from the server",
-                "action": 'store_true',
-                "default": False,
-            }),
-            (["-r", "--reverse"], {
-                "action": "store_true",
-                "default": False,
-                "help": "Reverse the order of the shown notifications (newest on top)",
-            }),
-            (["-m", "--mentions"], {
-                "action": "store_true",
-                "default": False,
-                "help": "Only print mentions",
-            })
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="instance",
-        description="Display instance details",
-        arguments=[
-            (["instance"], {
-                "help": "instance domain (e.g. 'mastodon.social') or blank to use current",
-                "nargs": "?",
-            }),
-            scheme_arg,
-            json_arg,
-        ],
-        require_auth=False,
-    ),
-    Command(
-        name="search",
-        description="Search for users or hashtags",
-        arguments=[
-            (["query"], {
-                "help": "the search query",
-            }),
-            (["-r", "--resolve"], {
-                "action": 'store_true',
-                "default": False,
-                "help": "Resolve non-local accounts",
-            }),
-            json_arg,
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="thread",
-        description="Show toot thread items",
-        arguments=[
-            (["status_id"], {
-                "help": "Show thread for toot.",
-            }),
-            json_arg,
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="status",
-        description="Show a single status",
-        arguments=[
-            (["status_id"], {
-                "help": "ID of the status to show.",
-            }),
-            json_arg,
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="timeline",
-        description="Show recent items in a timeline (home by default)",
-        arguments=timeline_args,
-        require_auth=True,
-    ),
-    Command(
-        name="bookmarks",
-        description="Show bookmarked posts",
-        arguments=timeline_and_bookmark_args,
-        require_auth=True,
-    ),
-]
-
-POST_COMMANDS = [
-    Command(
-        name="post",
-        description="Post a status text to your timeline",
-        arguments=[
-            (["text"], {
-                "help": "The status text to post.",
-                "nargs": "?",
-            }),
-            (["-m", "--media"], {
-                "action": "append",
-                "type": FileType("rb"),
-                "help": "path to the media file to attach (specify multiple "
-                        "times to attach up to 4 files)"
-            }),
-            (["-d", "--description"], {
-                "action": "append",
-                "type": str,
-                "help": "plain-text description of the media for accessibility "
-                        "purposes, one per attached media"
-            }),
-            (["--thumbnail"], {
-                "action": "append",
-                "type": FileType("rb"),
-                "help": "path to an image file to serve as media thumbnail, "
-                        "one per attached media"
-            }),
-            visibility_arg,
-            (["-s", "--sensitive"], {
-                "action": 'store_true',
-                "default": False,
-                "help": "mark the media as NSFW",
-            }),
-            (["-p", "--spoiler-text"], {
-                "type": str,
-                "help": "text to be shown as a warning before the actual content",
-            }),
-            (["-r", "--reply-to"], {
-                "type": str,
-                "help": "local ID of the status you want to reply to",
-            }),
-            (["-l", "--language"], {
-                "type": language,
-                "help": "ISO 639-1 language code of the toot, to skip automatic detection",
-            }),
-            (["-e", "--editor"], {
-                "type": editor,
-                "nargs": "?",
-                "const": os.getenv("EDITOR", ""),  # option given without value
-                "help": "Specify an editor to compose your toot, "
-                        "defaults to editor defined in $EDITOR env variable.",
-            }),
-            (["--scheduled-at"], {
-                "type": str,
-                "help": "ISO 8601 Datetime at which to schedule a status. Must "
-                        "be at least 5 minutes in the future.",
-            }),
-            (["--scheduled-in"], {
-                "type": duration,
-                "help": f"""Schedule the toot to be posted after a given amount
-                        of time, {DURATION_EXAMPLES}. Must be at least 5
-                        minutes.""",
-            }),
-            (["-t", "--content-type"], {
-                "type": str,
-                "help": "MIME type for the status text (not supported on all instances)",
-            }),
-            (["--poll-option"], {
-                "action": "append",
-                "type": str,
-                "help": "Possible answer to the poll"
-            }),
-            (["--poll-expires-in"], {
-                "type": duration,
-                "help": f"""Duration that the poll should be open,
-                        {DURATION_EXAMPLES}. Defaults to 24h.""",
-                "default": 24 * 60 * 60,
-            }),
-            (["--poll-multiple"], {
-                "action": "store_true",
-                "default": False,
-                "help": "Allow multiple answers to be selected."
-            }),
-            (["--poll-hide-totals"], {
-                "action": "store_true",
-                "default": False,
-                "help": "Hide vote counts until the poll ends. Defaults to false."
-            }),
-            json_arg,
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="upload",
-        description="Upload an image or video file",
-        arguments=[
-            (["file"], {
-                "help": "Path to the file to upload",
-                "type": FileType('rb')
-            }),
-            (["-d", "--description"], {
-                "type": str,
-                "help": "plain-text description of the media for accessibility purposes"
-            }),
-        ],
-        require_auth=True,
-    ),
-]
-
-STATUS_COMMANDS = [
-    Command(
-        name="delete",
-        description="Delete a status",
-        arguments=[status_id_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="favourite",
-        description="Favourite a status",
-        arguments=[status_id_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="unfavourite",
-        description="Unfavourite a status",
-        arguments=[status_id_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="reblog",
-        description="Reblog a status",
-        arguments=[status_id_arg, visibility_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="unreblog",
-        description="Unreblog a status",
-        arguments=[status_id_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="reblogged_by",
-        description="Show accounts that reblogged the status",
-        arguments=[status_id_arg, json_arg],
-        require_auth=False,
-    ),
-    Command(
-        name="pin",
-        description="Pin a status",
-        arguments=[status_id_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="unpin",
-        description="Unpin a status",
-        arguments=[status_id_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="bookmark",
-        description="Bookmark a status",
-        arguments=[status_id_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="unbookmark",
-        description="Unbookmark a status",
-        arguments=[status_id_arg, json_arg],
-        require_auth=True,
-    ),
-]
-
-ACCOUNTS_COMMANDS = [
-    Command(
-        name="follow",
-        description="Follow an account",
-        arguments=[account_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="unfollow",
-        description="Unfollow an account",
-        arguments=[account_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="following",
-        description="List accounts followed by the given account, " +
-                    "or your account if no account given",
-        arguments=[optional_account_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="followers",
-        description="List accounts following the given account, " +
-                    "or your account if no account given",
-        arguments=[optional_account_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="mute",
-        description="Mute an account",
-        arguments=[account_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="unmute",
-        description="Unmute an account",
-        arguments=[account_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="muted",
-        description="List muted accounts",
-        arguments=[json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="block",
-        description="Block an account",
-        arguments=[account_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="unblock",
-        description="Unblock an account",
-        arguments=[account_arg, json_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="blocked",
-        description="List blocked accounts",
-        arguments=[json_arg],
-        require_auth=True,
-    ),
-]
-
-TAG_COMMANDS = [
-    Command(
-        name="tags_followed",
-        description="List hashtags you follow",
-        arguments=[],
-        require_auth=True,
-    ),
-    Command(
-        name="tags_follow",
-        description="Follow a hashtag",
-        arguments=[tag_arg],
-        require_auth=True,
-    ),
-    Command(
-        name="tags_unfollow",
-        description="Unfollow a hashtag",
-        arguments=[tag_arg],
-        require_auth=True,
-    ),
-]
-
-LIST_COMMANDS = [
-    Command(
-        name="lists",
-        description="List all lists",
-        arguments=[],
-        require_auth=True,
-    ),
-    Command(
-        name="list_accounts",
-        description="List the accounts in a list",
-        arguments=[
-            (["--id"], {
-                "type": str,
-                "help": "ID of the list"
-            }),
-            (["title"], {
-                "type": str,
-                "nargs": "?",
-                "help": "title of the list"
-            }),
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="list_create",
-        description="Create a list",
-        arguments=[
-            (["title"], {
-                "type": str,
-                "help": "title of the list"
-            }),
-            (["--replies-policy"], {
-                "type": str,
-                "help": "replies policy: 'followed', 'list', or 'none' (defaults to 'none')"
-            }),
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="list_delete",
-        description="Delete a list",
-        arguments=[
-            (["--id"], {
-                "type": str,
-                "help": "ID of the list"
-            }),
-            (["title"], {
-                "type": str,
-                "nargs": "?",
-                "help": "title of the list"
-            }),
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="list_add",
-        description="Add account to list",
-        arguments=[
-            (["--id"], {
-                "type": str,
-                "help": "ID of the list"
-            }),
-            (["title"], {
-                "type": str,
-                "nargs": "?",
-                "help": "title of the list"
-            }),
-            (["account"], {
-                "type": str,
-                "help": "Account to add"
-            }),
-        ],
-        require_auth=True,
-    ),
-    Command(
-        name="list_remove",
-        description="Remove account from list",
-        arguments=[
-            (["--id"], {
-                "type": str,
-                "help": "ID of the list"
-            }),
-            (["title"], {
-                "type": str,
-                "nargs": "?",
-                "help": "title of the list"
-            }),
-            (["account"], {
-                "type": str,
-                "help": "Account to remove"
-            }),
-        ],
-        require_auth=True,
-    ),
-]
-COMMAND_GROUPS = [
-    ("Authentication", AUTH_COMMANDS),
-    ("TUI", TUI_COMMANDS),
-    ("Read", READ_COMMANDS),
-    ("Post", POST_COMMANDS),
-    ("Status", STATUS_COMMANDS),
-    ("Accounts", ACCOUNTS_COMMANDS),
-    ("Hashtags", TAG_COMMANDS),
-    ("Lists", LIST_COMMANDS),
-]
-
-COMMANDS = list(chain(*[commands for _, commands in COMMAND_GROUPS]))
-
-
-def print_usage():
-    max_name_len = max(len(name) for name, _ in COMMAND_GROUPS)
-
-    print_out("<green>{}</green>".format(CLIENT_NAME))
-    print_out("<blue>v{}</blue>".format(__version__))
-
-    for name, cmds in COMMAND_GROUPS:
-        print_out("")
-        print_out(name + ":")
-
-        for cmd in cmds:
-            cmd_name = cmd.name.ljust(max_name_len + 2)
-            print_out("  <yellow>toot {}</yellow> {}".format(cmd_name, cmd.description))
-
-    print_out("")
-    print_out("To get help for each command run:")
-    print_out("  <yellow>toot \\<command> --help</yellow>")
-    print_out("")
-    print_out("<green>{}</green>".format(CLIENT_WEBSITE))
-
-
-def get_argument_parser(name, command):
-    parser = ArgumentParser(
-        prog='toot %s' % name,
-        description=command.description,
-        epilog=CLIENT_WEBSITE)
-
-    combined_args = command.arguments + common_args
-    if command.require_auth:
-        combined_args += common_auth_args
-
-    defaults = get_setting(f"commands.{name}", dict, {})
-
-    for args, kwargs in combined_args:
-        # Set default value from settings if exists
-        default = get_default_value(defaults, args)
-        if default is not None:
-            kwargs["default"] = default
-        parser.add_argument(*args, **kwargs)
-
-    return parser
-
-
-def get_default_value(defaults, args):
-    # Hacky way to determine command name from argparse args
-    name = args[-1].lstrip("-").replace("-", "_")
-    return defaults.get(name)
-
-
-def run_command(app, user, name, args):
-    command = next((c for c in COMMANDS if c.name == name), None)
-
-    if not command:
-        print_err(f"Unknown command '{name}'")
-        print_out("Run <yellow>toot --help</yellow> to show a list of available commands.")
-        return
-
-    parser = get_argument_parser(name, command)
-    parsed_args = parser.parse_args(args)
-
-    # Override the active account if 'using' option is given
-    if command.require_auth and parsed_args.using:
-        user, app = config.get_user_app(parsed_args.using)
-        if not user or not app:
-            raise ConsoleError("User '{}' not found".format(parsed_args.using))
-
-    if command.require_auth and (not user or not app):
-        print_err("This command requires that you are logged in.")
-        print_err("Please run `toot login` first.")
-        return
-
-    fn = commands.__dict__.get(name)
-
-    if not fn:
-        raise NotImplementedError("Command '{}' does not have an implementation.".format(name))
-
-    return fn(app, user, parsed_args)
-
-
-def main():
-    if settings.get_debug():
-        filename = settings.get_debug_file()
-        logging.basicConfig(level=logging.DEBUG, filename=filename)
-        logging.getLogger("urllib3").setLevel(logging.INFO)
-
-    command_name = sys.argv[1] if len(sys.argv) > 1 else None
-    args = sys.argv[2:]
-
-    if not command_name or command_name == "--help":
-        return print_usage()
-
-    user, app = config.get_active_user_app()
-
-    try:
-        run_command(app, user, command_name, args)
-    except (ConsoleError, ApiError) as e:
-        print_err(str(e))
-        sys.exit(1)
-    except KeyboardInterrupt:
-        pass
diff --git a/toot/entities.py b/toot/entities.py
index 8ef51e3c..309d84ee 100644
--- a/toot/entities.py
+++ b/toot/entities.py
@@ -409,6 +409,42 @@ class Relationship:
     note: str
 
 
+@dataclass
+class TagHistory:
+    """
+    Usage statistics for given days (typically the past week).
+    https://docs.joinmastodon.org/entities/Tag/#history
+    """
+    day: str
+    uses: str
+    accounts: str
+
+
+@dataclass
+class Tag:
+    """
+    Represents a hashtag used within the content of a status.
+    https://docs.joinmastodon.org/entities/Tag/
+    """
+    name: str
+    url: str
+    history: List[TagHistory]
+    following: Optional[bool]
+
+
+@dataclass
+class FeaturedTag:
+    """
+    Represents a hashtag that is featured on a profile.
+    https://docs.joinmastodon.org/entities/FeaturedTag/
+    """
+    id: str
+    name: str
+    url: str
+    statuses_count: int
+    last_status_at: datetime
+
+
 # Generic data class instance
 T = TypeVar("T")
 
diff --git a/toot/exceptions.py b/toot/exceptions.py
index 2bf495d6..c5e23500 100644
--- a/toot/exceptions.py
+++ b/toot/exceptions.py
@@ -1,4 +1,7 @@
-class ApiError(Exception):
+from click import ClickException
+
+
+class ApiError(ClickException):
     """Raised when an API request fails for whatever reason."""
 
 
@@ -10,5 +13,5 @@ class AuthenticationError(ApiError):
     """Raised when login fails."""
 
 
-class ConsoleError(Exception):
+class ConsoleError(ClickException):
     """Raised when an error occurs which needs to be show to the user."""
diff --git a/toot/output.py b/toot/output.py
index d526539f..266f467a 100644
--- a/toot/output.py
+++ b/toot/output.py
@@ -1,227 +1,122 @@
-import os
+import click
 import re
-import sys
 import textwrap
+import shutil
 
-from functools import lru_cache
-from toot import settings
-from toot.utils import get_text, html_to_paragraphs
 from toot.entities import Account, Instance, Notification, Poll, Status
+from toot.utils import get_text, html_to_paragraphs
 from toot.wcstring import wc_wrap
-from typing import Iterable, List
+from typing import Any, Generator, Iterable, List
 from wcwidth import wcswidth
 
 
-STYLES = {
-    'reset': '\033[0m',
-    'bold': '\033[1m',
-    'dim': '\033[2m',
-    'italic': '\033[3m',
-    'underline': '\033[4m',
-    'red': '\033[91m',
-    'green': '\033[92m',
-    'yellow': '\033[93m',
-    'blue': '\033[94m',
-    'magenta': '\033[95m',
-    'cyan': '\033[96m',
-}
-
-STYLE_TAG_PATTERN = re.compile(r"""
-    (?<!\\)     # not preceeded by a backslash - allows escaping
-    <           # literal
-    (/)?        # optional closing - first group
-    (.*?)       # style names - ungreedy - second group
-    >           # literal
-""", re.X)
-
-
-def colorize(message):
-    """
-    Replaces style tags in `message` with ANSI escape codes.
-
-    Markup is inspired by HTML, but you can use multiple words pre tag, e.g.:
-
-        <red bold>alert!</red bold> a thing happened
-
-    Empty closing tag will reset all styes:
-
-        <red bold>alert!</> a thing happened
+DEFAULT_WIDTH = 80
 
-    Styles can be nested:
 
-        <red>red <underline>red and underline</underline> red</red>
-    """
+def get_max_width() -> int:
+    return click.get_current_context().max_content_width or DEFAULT_WIDTH
 
-    def _codes(styles):
-        for style in styles:
-            yield STYLES.get(style, "")
-
-    def _generator(message):
-        # A list is used instead of a set because we want to keep style order
-        # This allows nesting colors, e.g. "<blue>foo<red>bar</red>baz</blue>"
-        position = 0
-        active_styles = []
-
-        for match in re.finditer(STYLE_TAG_PATTERN, message):
-            is_closing = bool(match.group(1))
-            styles = match.group(2).strip().split()
-
-            start, end = match.span()
-            # Replace backslash for escaped <
-            yield message[position:start].replace("\\<", "<")
-
-            if is_closing:
-                yield STYLES["reset"]
-
-                # Empty closing tag resets all styles
-                if styles == []:
-                    active_styles = []
-                else:
-                    active_styles = [s for s in active_styles if s not in styles]
-                    yield from _codes(active_styles)
-            else:
-                active_styles = active_styles + styles
-                yield from _codes(styles)
-
-            position = end
-
-        if position == 0:
-            # Nothing matched, yield the original string
-            yield message
-        else:
-            # Yield the remaining fragment
-            yield message[position:]
-            # Reset styles at the end to prevent leaking
-            yield STYLES["reset"]
 
-    return "".join(_generator(message))
+def get_terminal_width() -> int:
+    return shutil.get_terminal_size().columns
 
 
-def strip_tags(message):
-    return re.sub(STYLE_TAG_PATTERN, "", message)
+def get_width() -> int:
+    return min(get_terminal_width(), get_max_width())
 
 
-@lru_cache(maxsize=None)
-def use_ansi_color():
-    """Returns True if ANSI color codes should be used."""
+def print_warning(text: str):
+    click.secho(f"Warning: {text}", fg="yellow", err=True)
 
-    # Windows doesn't support color unless ansicon is installed
-    # See: http://adoxa.altervista.org/ansicon/
-    if sys.platform == 'win32' and 'ANSICON' not in os.environ:
-        return False
 
-    # Don't show color if stdout is not a tty, e.g. if output is piped on
-    if not sys.stdout.isatty():
-        return False
-
-    # Don't show color if explicitly specified in options
-    if "--no-color" in sys.argv:
-        return False
-
-    # Check in settings
-    color = settings.get_setting("common.color", bool)
-    if color is not None:
-        return color
-
-    # Use color by default
-    return True
-
-
-def print_out(*args, **kwargs):
-    if not settings.get_quiet():
-        args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
-        print(*args, **kwargs)
+def print_instance(instance: Instance):
+    width = get_width()
+    click.echo(instance_to_text(instance, width))
 
 
-def print_err(*args, **kwargs):
-    args = [f"<red>{a}</red>" for a in args]
-    args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
-    print(*args, file=sys.stderr, **kwargs)
+def instance_to_text(instance: Instance, width: int) -> str:
+    return "\n".join(instance_lines(instance, width))
 
 
-def print_instance(instance: Instance):
-    print_out(f"<green>{instance.title}</green>")
-    print_out(f"<blue>{instance.uri}</blue>")
-    print_out(f"running Mastodon {instance.version}")
-    print_out()
+def instance_lines(instance: Instance, width: int) -> Generator[str, None, None]:
+    yield f"{green(instance.title)}"
+    yield f"{blue(instance.uri)}"
+    yield f"running Mastodon {instance.version}"
+    yield ""
 
     if instance.description:
         for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
             paragraph = get_text(paragraph)
-            print_out(textwrap.fill(paragraph, width=80))
-            print_out()
+            yield textwrap.fill(paragraph, width=width)
+            yield ""
 
     if instance.rules:
-        print_out("Rules:")
+        yield "Rules:"
         for ordinal, rule in enumerate(instance.rules):
             ordinal = f"{ordinal + 1}."
-            lines = textwrap.wrap(rule.text, 80 - len(ordinal))
+            lines = textwrap.wrap(rule.text, width - len(ordinal))
             first = True
             for line in lines:
                 if first:
-                    print_out(f"{ordinal} {line}")
+                    yield f"{ordinal} {line}"
                     first = False
                 else:
-                    print_out(f"{' ' * len(ordinal)} {line}")
-        print_out()
+                    yield f"{' ' * len(ordinal)} {line}"
+        yield ""
 
     contact = instance.contact_account
     if contact:
-        print_out(f"Contact: {contact.display_name} @{contact.acct}")
+        yield f"Contact: {contact.display_name} @{contact.acct}"
 
 
-def print_account(account: Account):
-    print_out(f"<green>@{account.acct}</green> {account.display_name}")
+def print_account(account: Account) -> None:
+    width = get_width()
+    click.echo(account_to_text(account, width))
 
-    if account.note:
-        print_out("")
-        print_html(account.note)
 
-    since = account.created_at.strftime('%Y-%m-%d')
+def account_to_text(account: Account, width: int) -> str:
+    return "\n".join(account_lines(account, width))
 
-    print_out("")
-    print_out(f"ID: <green>{account.id}</green>")
-    print_out(f"Since: <green>{since}</green>")
-    print_out("")
-    print_out(f"Followers: <yellow>{account.followers_count}</yellow>")
-    print_out(f"Following: <yellow>{account.following_count}</yellow>")
-    print_out(f"Statuses: <yellow>{account.statuses_count}</yellow>")
 
-    if account.fields:
-        for field in account.fields:
-            name = field.name.title()
-            print_out(f'\n<yellow>{name}</yellow>:')
-            print_html(field.value)
-            if field.verified_at:
-                print_out("<green>✓ Verified</green>")
+def account_lines(account: Account, width: int) -> Generator[str, None, None]:
+    acct = f"@{account.acct}"
+    since = account.created_at.strftime("%Y-%m-%d")
 
-    print_out("")
-    print_out(account.url)
+    yield f"{green(acct)} {account.display_name}"
 
+    if account.note:
+        yield ""
+        yield from html_lines(account.note, width)
 
-HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
+    yield ""
+    yield f"ID: {green(account.id)}"
+    yield f"Since: {green(since)}"
+    yield ""
+    yield f"Followers: {yellow(account.followers_count)}"
+    yield f"Following: {yellow(account.following_count)}"
+    yield f"Statuses: {yellow(account.statuses_count)}"
 
+    if account.fields:
+        for field in account.fields:
+            name = field.name.title()
+            yield f'\n{yellow(name)}:'
+            yield from html_lines(field.value, width)
+            if field.verified_at:
+                yield green("✓ Verified")
 
-def highlight_hashtags(line):
-    return re.sub(HASHTAG_PATTERN, '<cyan>\\1</cyan>', line)
+    yield ""
+    yield account.url
 
 
 def print_acct_list(accounts):
     for account in accounts:
-        print_out(f"* <green>@{account['acct']}</green> {account['display_name']}")
-
-
-def print_user_list(users):
-    for user in users:
-        print_out(f"* {user}")
+        acct = green(f"@{account['acct']}")
+        click.echo(f"* {acct} {account['display_name']}")
 
 
 def print_tag_list(tags):
-    if tags:
-        for tag in tags:
-            print_out(f"* <green>#{tag['name']}\t</green>{tag['url']}")
-    else:
-        print_out("You're not following any hashtags.")
+    for tag in tags:
+        click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
 
 
 def print_lists(lists):
@@ -234,20 +129,17 @@ def print_table(headers: List[str], data: List[List[str]]):
     widths = [[len(cell) for cell in row] for row in data + [headers]]
     widths = [max(width) for width in zip(*widths)]
 
-    def style(string, tag):
-        return f"<{tag}>{string}</{tag}>" if tag else string
-
-    def print_row(row, tag=None):
+    def print_row(row):
         for idx, cell in enumerate(row):
             width = widths[idx]
-            print_out(style(cell.ljust(width), tag), end="")
-            print_out("  ", end="")
-        print_out()
+            click.echo(cell.ljust(width), nl=False)
+            click.echo("  ", nl=False)
+        click.echo()
 
     underlines = ["-" * width for width in widths]
 
-    print_row(headers, "bold")
-    print_row(underlines, "dim")
+    print_row(headers)
+    print_row(underlines)
 
     for row in data:
         print_row(row)
@@ -255,33 +147,42 @@ def print_row(row, tag=None):
 
 def print_list_accounts(accounts):
     if accounts:
-        print_out("Accounts in list</green>:\n")
+        click.echo("Accounts in list:\n")
         print_acct_list(accounts)
     else:
-        print_out("This list has no accounts.")
+        click.echo("This list has no accounts.")
 
 
 def print_search_results(results):
-    accounts = results['accounts']
-    hashtags = results['hashtags']
+    accounts = results["accounts"]
+    hashtags = results["hashtags"]
 
     if accounts:
-        print_out("\nAccounts:")
+        click.echo("\nAccounts:")
         print_acct_list(accounts)
 
     if hashtags:
-        print_out("\nHashtags:")
-        print_out(", ".join([f"<green>#{t['name']}</green>" for t in hashtags]))
+        click.echo("\nHashtags:")
+        click.echo(", ".join([format_tag_name(tag) for tag in hashtags]))
 
     if not accounts and not hashtags:
-        print_out("<yellow>Nothing found</yellow>")
+        click.echo("Nothing found")
+
 
+def print_status(status: Status) -> None:
+    width = get_width()
+    click.echo(status_to_text(status, width))
 
-def print_status(status: Status, width: int = 80):
+
+def status_to_text(status: Status, width: int) -> str:
+    return "\n".join(status_lines(status))
+
+
+def status_lines(status: Status) -> Generator[str, None, None]:
+    width = get_width()
     status_id = status.id
     in_reply_to_id = status.in_reply_to_id
     reblogged_by = status.account if status.reblog else None
-
     status = status.original
 
     time = status.created_at.strftime('%Y-%m-%d %H:%M %Z')
@@ -289,61 +190,60 @@ def print_status(status: Status, width: int = 80):
     spacing = width - wcswidth(username) - wcswidth(time) - 2
 
     display_name = status.account.display_name
+
     if display_name:
+        author = f"{green(display_name)} {blue(username)}"
         spacing -= wcswidth(display_name) + 1
+    else:
+        author = blue(username)
 
-    print_out(
-        f"<green>{display_name}</green>" if display_name else "",
-        f"<blue>{username}</blue>",
-        " " * spacing,
-        f"<yellow>{time}</yellow>",
-    )
+    spaces = " " * spacing
+    yield f"{author} {spaces} {yellow(time)}"
 
-    print_out("")
-    print_html(status.content, width)
+    yield ""
+    yield from html_lines(status.content, width)
 
     if status.media_attachments:
-        print_out("\nMedia:")
+        yield ""
+        yield "Media:"
         for attachment in status.media_attachments:
             url = attachment.url
             for line in wc_wrap(url, width):
-                print_out(line)
+                yield line
 
     if status.poll:
-        print_poll(status.poll)
+        yield from poll_lines(status.poll)
 
-    print_out()
+    reblogged_by_acct = f"@{reblogged_by.acct}" if reblogged_by else None
+    yield ""
 
-    print_out(
-        f"ID <yellow>{status_id}</yellow> ",
-        f"↲ In reply to <yellow>{in_reply_to_id}</yellow> " if in_reply_to_id else "",
-        f"↻ <blue>@{reblogged_by.acct}</blue> boosted " if reblogged_by else "",
-    )
+    reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
+    boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else ""
+    yield f"ID {yellow(status_id)} {reply} {boost}"
 
 
-def print_html(text, width=80):
+def html_lines(html: str, width: int) -> Generator[str, None, None]:
     first = True
-    for paragraph in html_to_paragraphs(text):
+    for paragraph in html_to_paragraphs(html):
         if not first:
-            print_out("")
+            yield ""
         for line in paragraph:
             for subline in wc_wrap(line, width):
-                print_out(highlight_hashtags(subline))
+                yield subline
         first = False
 
 
-def print_poll(poll: Poll):
-    print_out()
+def poll_lines(poll: Poll) -> Generator[str, None, None]:
     for idx, option in enumerate(poll.options):
         perc = (round(100 * option.votes_count / poll.votes_count)
             if poll.votes_count and option.votes_count is not None else 0)
 
         if poll.voted and poll.own_votes and idx in poll.own_votes:
-            voted_for = " <yellow>✓</yellow>"
+            voted_for = yellow(" ✓")
         else:
             voted_for = ""
 
-        print_out(f'{option.title} - {perc}% {voted_for}')
+        yield f"{option.title} - {perc}% {voted_for}"
 
     poll_footer = f'Poll · {poll.votes_count} votes'
 
@@ -354,38 +254,86 @@ def print_poll(poll: Poll):
         expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
         poll_footer += f" · Closes on {expires_at}"
 
-    print_out()
-    print_out(poll_footer)
+    yield ""
+    yield poll_footer
 
 
-def print_timeline(items: Iterable[Status], width=100):
-    print_out("─" * width)
+def print_timeline(items: Iterable[Status]):
+    print_divider()
     for item in items:
-        print_status(item, width)
-        print_out("─" * width)
-
-
-notification_msgs = {
-    "follow": "{account} now follows you",
-    "mention": "{account} mentioned you in",
-    "reblog": "{account} reblogged your status",
-    "favourite": "{account} favourited your status",
-}
-
+        print_status(item)
+        print_divider()
 
-def print_notification(notification: Notification, width=100):
-    account = f"{notification.account.display_name} @{notification.account.acct}"
-    msg = notification_msgs.get(notification.type)
-    if msg is None:
-        return
 
-    print_out("─" * width)
-    print_out(msg.format(account=account))
+def print_notification(notification: Notification):
+    print_notification_header(notification)
     if notification.status:
-        print_status(notification.status, width)
+        print_divider(char="-")
+        print_status(notification.status)
 
 
-def print_notifications(notifications: List[Notification], width=100):
+def print_notifications(notifications: List[Notification]):
     for notification in notifications:
+        print_divider()
         print_notification(notification)
-    print_out("─" * width)
+    print_divider()
+
+
+def print_notification_header(notification: Notification):
+    account_name = format_account_name(notification.account)
+
+    if (notification.type == "follow"):
+        click.echo(f"{account_name} now follows you")
+    elif (notification.type == "mention"):
+        click.echo(f"{account_name} mentioned you")
+    elif (notification.type == "reblog"):
+        click.echo(f"{account_name} reblogged your status")
+    elif (notification.type == "favourite"):
+        click.echo(f"{account_name} favourited your status")
+    elif (notification.type == "update"):
+        click.echo(f"{account_name} edited a post")
+    else:
+        click.secho(f"Unknown notification type: '{notification.type}'", err=True, fg="yellow")
+        click.secho("Please report an issue to toot.", err=True, fg="yellow")
+
+
+def print_divider(char: str = "─"):
+    click.echo(char * get_width())
+
+
+def format_tag_name(tag):
+    return green(f"#{tag['name']}")
+
+
+def format_account_name(account: Account) -> str:
+    acct = blue(f"@{account.acct}")
+    if account.display_name:
+        return f"{green(account.display_name)} {acct}"
+    else:
+        return acct
+
+
+# Shorthand functions for coloring output
+
+def blue(text: Any) -> str:
+    return click.style(text, fg="blue")
+
+
+def bold(text: Any) -> str:
+    return click.style(text, bold=True)
+
+
+def cyan(text: Any) -> str:
+    return click.style(text, fg="cyan")
+
+
+def dim(text: Any) -> str:
+    return click.style(text, dim=True)
+
+
+def green(text: Any) -> str:
+    return click.style(text, fg="green")
+
+
+def yellow(text: Any) -> str:
+    return click.style(text, fg="yellow")
diff --git a/toot/settings.py b/toot/settings.py
index 90d44435..4da3322b 100644
--- a/toot/settings.py
+++ b/toot/settings.py
@@ -1,6 +1,3 @@
-import os
-import sys
-
 from functools import lru_cache
 from os.path import exists, join
 from tomlkit import parse
@@ -17,7 +14,7 @@ def get_settings_path():
     return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME)
 
 
-def load_settings() -> dict:
+def _load_settings() -> dict:
     # Used for testing without config file
     if DISABLE_SETTINGS:
         return {}
@@ -33,7 +30,7 @@ def load_settings() -> dict:
 
 @lru_cache(maxsize=None)
 def get_settings():
-    return load_settings()
+    return _load_settings()
 
 
 T = TypeVar("T")
@@ -62,26 +59,3 @@ def _get_setting(dct, keys, type: Type, default=None):
         return _get_setting(dct[key], keys[1:], type, default)
 
     return default
-
-
-def get_debug() -> bool:
-    if "--debug" in sys.argv:
-        return True
-
-    return get_setting("common.debug", bool, False)
-
-
-def get_debug_file() -> Optional[str]:
-    from_env = os.getenv("TOOT_LOG_FILE")
-    if from_env:
-        return from_env
-
-    return get_setting("common.debug_file", str)
-
-
-@lru_cache(maxsize=None)
-def get_quiet():
-    if "--quiet" in sys.argv:
-        return True
-
-    return get_setting("common.quiet", str, False)
diff --git a/toot/tui/app.py b/toot/tui/app.py
index 8a2ce090..82747a2d 100644
--- a/toot/tui/app.py
+++ b/toot/tui/app.py
@@ -3,9 +3,11 @@
 import urwid
 
 from concurrent.futures import ThreadPoolExecutor
+from typing import NamedTuple, Optional
 
 from toot import api, config, __version__, settings
-from toot.console import get_default_visibility
+from toot import App, User
+from toot.cli import get_default_visibility
 from toot.exceptions import ApiError
 
 from .compose import StatusComposer
@@ -25,6 +27,12 @@
 DEFAULT_MAX_TOOT_CHARS = 500
 
 
+class TuiOptions(NamedTuple):
+    colors: int
+    media_viewer: Optional[str]
+    relative_datetimes: bool
+
+
 class Header(urwid.WidgetWrap):
     def __init__(self, app, user):
         self.app = app
@@ -80,9 +88,11 @@ class TUI(urwid.Frame):
     screen: urwid.BaseScreen
 
     @staticmethod
-    def create(app, user, args):
+    def create(app: App, user: User, args: TuiOptions):
         """Factory method, sets up TUI and an event loop."""
-        screen = TUI.create_screen(args)
+        screen = urwid.raw_display.Screen()
+        screen.set_terminal_properties(args.colors)
+
         tui = TUI(app, user, screen, args)
 
         palette = PALETTE.copy()
@@ -101,23 +111,11 @@ def create(app, user, args):
 
         return tui
 
-    @staticmethod
-    def create_screen(args):
-        screen = urwid.raw_display.Screen()
-
-        # Determine how many colors to use
-        default_colors = 1 if args.no_color else 16
-        colors = settings.get_setting("tui.colors", int, default_colors)
-        logger.debug(f"Setting colors to {colors}")
-        screen.set_terminal_properties(colors)
-
-        return screen
-
-    def __init__(self, app, user, screen, args):
+    def __init__(self, app, user, screen, options: TuiOptions):
         self.app = app
         self.user = user
-        self.args = args
         self.config = config.load_config()
+        self.options = options
 
         self.loop = None  # late init, set in `create`
         self.screen = screen
@@ -139,7 +137,6 @@ def __init__(self, app, user, screen, args):
         self.can_translate = False
         self.account = None
         self.followed_accounts = []
-        self.media_viewer = settings.get_setting("tui.media_viewer", str)
 
         super().__init__(self.body, header=self.header, footer=self.footer)
 
@@ -503,8 +500,15 @@ def show_media(self, status):
         if not urls:
             return
 
-        if self.media_viewer:
-            subprocess.run([self.media_viewer] + urls)
+        media_viewer = self.options.media_viewer
+        if media_viewer:
+            try:
+                subprocess.run([media_viewer] + urls)
+            except FileNotFoundError:
+                self.footer.set_error_message(f"Media viewer not found: '{media_viewer}'")
+            except Exception as ex:
+                self.exception = ex
+                self.footer.set_error_message("Failed invoking media viewer. Press X to see exception.")
         else:
             self.footer.set_error_message("Media viewer not configured")
 
diff --git a/toot/tui/compose.py b/toot/tui/compose.py
index 05bfaaf5..c4a038ad 100644
--- a/toot/tui/compose.py
+++ b/toot/tui/compose.py
@@ -1,7 +1,7 @@
 import urwid
 import logging
 
-from toot.console import get_default_visibility
+from toot.cli import get_default_visibility
 
 from .constants import VISIBILITY_OPTIONS
 from .widgets import Button, EditBox
diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py
index 60dfeb0a..63eb4b36 100644
--- a/toot/tui/timeline.py
+++ b/toot/tui/timeline.py
@@ -79,7 +79,7 @@ def build_status_list(self, statuses, focus):
         return urwid.ListBox(walker)
 
     def build_list_item(self, status):
-        item = StatusListItem(status, self.tui.args.relative_datetimes)
+        item = StatusListItem(status, self.tui.options.relative_datetimes)
         urwid.connect_signal(item, "click", lambda *args:
             self.tui.show_context_menu(status))
         return urwid.AttrMap(item, None, focus_map={
@@ -95,7 +95,7 @@ def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
             return None
 
         poll = status.original.data.get("poll")
-        show_media = status.original.data["media_attachments"] and self.tui.media_viewer
+        show_media = status.original.data["media_attachments"] and self.tui.options.media_viewer
 
         options = [
             "[A]ccount" if not status.is_mine else "",
@@ -107,7 +107,6 @@ def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
             "[T]hread" if not self.is_thread else "",
             "L[i]nks",
             "[M]edia" if show_media else "",
-            self.tui.media_viewer,
             "[R]eply",
             "[P]oll" if poll and not poll["expired"] else "",
             "So[u]rce",
diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py
index c4afa7ff..7bc5c774 100644
--- a/toot/utils/__init__.py
+++ b/toot/utils/__init__.py
@@ -7,7 +7,9 @@
 import warnings
 
 from bs4 import BeautifulSoup
-from typing import Dict
+from typing import Any, Dict, List
+
+import click
 
 from toot.exceptions import ConsoleError
 from urllib.parse import urlparse, urlencode, quote, unquote
@@ -38,7 +40,7 @@ def get_text(html):
     return unicodedata.normalize("NFKC", text)
 
 
-def html_to_paragraphs(html):
+def html_to_paragraphs(html: str) -> List[List[str]]:
     """Attempt to convert html to plain text while keeping line breaks.
     Returns a list of paragraphs, each being a list of lines.
     """
@@ -109,7 +111,7 @@ def multiline_input():
 """
 
 
-def editor_input(editor: str, initial_text: str):
+def editor_input(editor: str, initial_text: str) -> str:
     """Lets user input text using an editor."""
     tmp_path = _tmp_status_path()
     initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS
@@ -125,18 +127,7 @@ def editor_input(editor: str, initial_text: str):
         return f.read().split(EDITOR_DIVIDER)[0].strip()
 
 
-def read_char(values, default):
-    values = [v.lower() for v in values]
-
-    while True:
-        value = input().lower()
-        if value == "":
-            return default
-        if value in values:
-            return value
-
-
-def delete_tmp_status_file():
+def delete_tmp_status_file() -> None:
     try:
         os.unlink(_tmp_status_path())
     except FileNotFoundError:
@@ -148,50 +139,23 @@ def _tmp_status_path() -> str:
     return f"{tmp_dir}/.status.toot"
 
 
-def _use_existing_tmp_file(tmp_path) -> bool:
-    from toot.output import print_out
-
+def _use_existing_tmp_file(tmp_path: str) -> bool:
     if os.path.exists(tmp_path):
-        print_out(f"<cyan>Found a draft status at: {tmp_path}</cyan>")
-        print_out("<cyan>[O]pen (default) or [D]elete?</cyan> ", end="")
-        char = read_char(["o", "d"], "o")
-        return char == "o"
+        click.echo(f"Found draft status at: {tmp_path}")
+
+        choice = click.Choice(["O", "D"], case_sensitive=False)
+        char = click.prompt("Open or Delete?", type=choice, default="O")
+        return char == "O"
 
     return False
 
 
-def drop_empty_values(data: Dict) -> Dict:
+def drop_empty_values(data: Dict[Any, Any]) -> Dict[Any, Any]:
     """Remove keys whose values are null"""
     return {k: v for k, v in data.items() if v is not None}
 
 
-def args_get_instance(instance, scheme, default=None):
-    if not instance:
-        return default
-
-    if scheme == "http":
-        _warn_scheme_deprecated()
-
-    if instance.startswith("http"):
-        return instance.rstrip("/")
-    else:
-        return f"{scheme}://{instance}"
-
-
-def _warn_scheme_deprecated():
-    from toot.output import print_err
-
-    print_err("\n".join([
-        "--disable-https flag is deprecated and will be removed.",
-        "Please specify the instance as URL instead.",
-        "e.g. instead of writing:",
-        "  toot instance unsafehost.com --disable-https",
-        "instead write:",
-        "  toot instance http://unsafehost.com\n"
-    ]))
-
-
-def urlencode_url(url):
+def urlencode_url(url: str) -> str:
     parsed_url = urlparse(url)
 
     # unencode before encoding, to prevent double-urlencoding


Test patch:
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000..5ee64771
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+testpaths = tests
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 8fcd1cb1..fea6477c 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -20,11 +20,10 @@
 import pytest
 import uuid
 
+from click.testing import CliRunner, Result
 from pathlib import Path
 from toot import api, App, User
-from toot.console import run_command
-from toot.exceptions import ApiError, ConsoleError
-from toot.output import print_out
+from toot.cli import Context, TootObj
 
 
 def pytest_configure(config):
@@ -34,6 +33,7 @@ def pytest_configure(config):
 
 # Mastodon database name, used to confirm user registration without having to click the link
 DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
+TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL")
 
 # Toot logo used for testing image upload
 TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png")
@@ -72,12 +72,10 @@ def confirm_user(email):
 # DO NOT USE PUBLIC INSTANCES!!!
 @pytest.fixture(scope="session")
 def base_url():
-    base_url = os.getenv("TOOT_TEST_BASE_URL")
-
-    if not base_url:
+    if not TOOT_TEST_BASE_URL:
         pytest.skip("Skipping integration tests, TOOT_TEST_BASE_URL not set")
 
-    return base_url
+    return TOOT_TEST_BASE_URL
 
 
 @pytest.fixture(scope="session")
@@ -105,37 +103,47 @@ def friend_id(app, user, friend):
     return api.find_account(app, user, friend.username)["id"]
 
 
+@pytest.fixture(scope="session", autouse=True)
+def testing_env():
+    os.environ["TOOT_TESTING"] = "true"
+
+
+@pytest.fixture(scope="session")
+def runner():
+    return CliRunner(mix_stderr=False)
+
+
 @pytest.fixture
-def run(app, user, capsys):
-    def _run(command, *params, as_user=None):
-        # The try/catch duplicates logic from console.main to convert exceptions
-        # to printed error messages. TODO: could be deduped
-        try:
-            run_command(app, as_user or user, command, params or [])
-        except (ConsoleError, ApiError) as e:
-            print_out(str(e))
-
-        out, err = capsys.readouterr()
-        assert err == ""
-        return strip_ansi(out)
+def run(app, user, runner):
+    def _run(command, *params, input=None) -> Result:
+        obj = TootObj(test_ctx=Context(app, user))
+        return runner.invoke(command, params, obj=obj, input=input)
     return _run
 
 
 @pytest.fixture
-def run_json(run):
+def run_as(app, runner):
+    def _run_as(user, command, *params, input=None) -> Result:
+        obj = TootObj(test_ctx=Context(app, user))
+        return runner.invoke(command, params, obj=obj, input=input)
+    return _run_as
+
+
+@pytest.fixture
+def run_json(app, user, runner):
     def _run_json(command, *params):
-        out = run(command, *params)
-        return json.loads(out)
+        obj = TootObj(test_ctx=Context(app, user))
+        result = runner.invoke(command, params, obj=obj)
+        assert result.exit_code == 0
+        return json.loads(result.stdout)
     return _run_json
 
 
 @pytest.fixture
-def run_anon(capsys):
-    def _run(command, *params):
-        run_command(None, None, command, params or [])
-        out, err = capsys.readouterr()
-        assert err == ""
-        return strip_ansi(out)
+def run_anon(runner):
+    def _run(command, *params) -> Result:
+        obj = TootObj(test_ctx=Context(None, None))
+        return runner.invoke(command, params, obj=obj)
     return _run
 
 
@@ -143,12 +151,6 @@ def _run(command, *params):
 # Utils
 # ------------------------------------------------------------------------------
 
-strip_ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
-
-
-def strip_ansi(string):
-    return strip_ansi_pattern.sub("", string).strip()
-
 
 def posted_status_id(out):
     pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)")
diff --git a/tests/integration/test_accounts.py b/tests/integration/test_accounts.py
index 0555e168..1caa807c 100644
--- a/tests/integration/test_accounts.py
+++ b/tests/integration/test_accounts.py
@@ -1,18 +1,23 @@
 import json
 
-from toot import App, User, api
+from toot import App, User, api, cli
 from toot.entities import Account, Relationship, from_dict
 
 
 def test_whoami(user: User, run):
-    out = run("whoami")
+    result = run(cli.read.whoami)
+    assert result.exit_code == 0
+
     # TODO: test other fields once updating account is supported
+    out = result.stdout.strip()
     assert f"@{user.username}" in out
 
 
 def test_whoami_json(user: User, run):
-    out = run("whoami", "--json")
-    account = from_dict(Account, json.loads(out))
+    result = run(cli.read.whoami, "--json")
+    assert result.exit_code == 0
+
+    account = from_dict(Account, json.loads(result.stdout))
     assert account.username == user.username
 
 
@@ -25,83 +30,95 @@ def test_whois(app: App, friend: User, run):
     ]
 
     for username in variants:
-        out = run("whois", username)
-        assert f"@{friend.username}" in out
+        result = run(cli.read.whois, username)
+        assert result.exit_code == 0
+        assert f"@{friend.username}" in result.stdout
 
 
 def test_following(app: App, user: User, friend: User, friend_id, run):
     # Make sure we're not initally following friend
     api.unfollow(app, user, friend_id)
 
-    out = run("following", user.username)
-    assert out == ""
+    result = run(cli.accounts.following, user.username)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == ""
 
-    out = run("follow", friend.username)
-    assert out == f"✓ You are now following {friend.username}"
+    result = run(cli.accounts.follow, friend.username)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == f"✓ You are now following {friend.username}"
 
-    out = run("following", user.username)
-    assert friend.username in out
+    result = run(cli.accounts.following, user.username)
+    assert result.exit_code == 0
+    assert friend.username in result.stdout.strip()
 
     # If no account is given defaults to logged in user
-    out = run("following")
-    assert friend.username in out
+    result = run(cli.accounts.following)
+    assert result.exit_code == 0
+    assert friend.username in result.stdout.strip()
 
-    out = run("unfollow", friend.username)
-    assert out == f"✓ You are no longer following {friend.username}"
+    result = run(cli.accounts.unfollow, friend.username)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == f"✓ You are no longer following {friend.username}"
 
-    out = run("following", user.username)
-    assert out == ""
+    result = run(cli.accounts.following, user.username)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == ""
 
 
 def test_following_case_insensitive(user: User, friend: User, run):
     assert friend.username != friend.username.upper()
-    out = run("follow", friend.username.upper())
+    result = run(cli.accounts.follow, friend.username.upper())
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == f"✓ You are now following {friend.username.upper()}"
 
 
 def test_following_not_found(run):
-    out = run("follow", "bananaman")
-    assert out == "Account not found"
+    result = run(cli.accounts.follow, "bananaman")
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Account not found"
 
-    out = run("unfollow", "bananaman")
-    assert out == "Account not found"
+    result = run(cli.accounts.unfollow, "bananaman")
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Account not found"
 
 
 def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json):
     # Make sure we're not initally following friend
     api.unfollow(app, user, friend_id)
 
-    result = run_json("following", user.username, "--json")
+    result = run_json(cli.accounts.following, user.username, "--json")
     assert result == []
 
-    result = run_json("followers", friend.username, "--json")
+    result = run_json(cli.accounts.followers, friend.username, "--json")
     assert result == []
 
-    result = run_json("follow", friend.username, "--json")
+    result = run_json(cli.accounts.follow, friend.username, "--json")
     relationship = from_dict(Relationship, result)
     assert relationship.id == friend_id
     assert relationship.following is True
 
-    [result] = run_json("following", user.username, "--json")
+    [result] = run_json(cli.accounts.following, user.username, "--json")
     relationship = from_dict(Relationship, result)
     assert relationship.id == friend_id
 
     # If no account is given defaults to logged in user
-    [result] = run_json("following", user.username, "--json")
+    [result] = run_json(cli.accounts.following, user.username, "--json")
     relationship = from_dict(Relationship, result)
     assert relationship.id == friend_id
 
-    [result] = run_json("followers", friend.username, "--json")
+    [result] = run_json(cli.accounts.followers, friend.username, "--json")
     assert result["id"] == user_id
 
-    result = run_json("unfollow", friend.username, "--json")
+    result = run_json(cli.accounts.unfollow, friend.username, "--json")
     assert result["id"] == friend_id
     assert result["following"] is False
 
-    result = run_json("following", user.username, "--json")
+    result = run_json(cli.accounts.following, user.username, "--json")
     assert result == []
 
-    result = run_json("followers", friend.username, "--json")
+    result = run_json(cli.accounts.followers, friend.username, "--json")
     assert result == []
 
 
@@ -109,57 +126,77 @@ def test_mute(app, user, friend, friend_id, run):
     # Make sure we're not initially muting friend
     api.unmute(app, user, friend_id)
 
-    out = run("muted")
+    result = run(cli.accounts.muted)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == "No accounts muted"
 
-    out = run("mute", friend.username)
+    result = run(cli.accounts.mute, friend.username)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == f"✓ You have muted {friend.username}"
 
-    out = run("muted")
+    result = run(cli.accounts.muted)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert friend.username in out
 
-    out = run("unmute", friend.username)
+    result = run(cli.accounts.unmute, friend.username)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == f"✓ {friend.username} is no longer muted"
 
-    out = run("muted")
+    result = run(cli.accounts.muted)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == "No accounts muted"
 
 
 def test_mute_case_insensitive(friend: User, run):
-    out = run("mute", friend.username.upper())
+    result = run(cli.accounts.mute, friend.username.upper())
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == f"✓ You have muted {friend.username.upper()}"
 
 
 def test_mute_not_found(run):
-    out = run("mute", "doesnotexistperson")
-    assert out == f"Account not found"
+    result = run(cli.accounts.mute, "doesnotexistperson")
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Account not found"
 
-    out = run("unmute", "doesnotexistperson")
-    assert out == f"Account not found"
+    result = run(cli.accounts.unmute, "doesnotexistperson")
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Account not found"
 
 
 def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
     # Make sure we're not initially muting friend
     api.unmute(app, user, friend_id)
 
-    result = run_json("muted", "--json")
+    result = run_json(cli.accounts.muted, "--json")
     assert result == []
 
-    result = run_json("mute", friend.username, "--json")
+    result = run_json(cli.accounts.mute, friend.username, "--json")
     relationship = from_dict(Relationship, result)
     assert relationship.id == friend_id
     assert relationship.muting is True
 
-    [result] = run_json("muted", "--json")
+    [result] = run_json(cli.accounts.muted, "--json")
     account = from_dict(Account, result)
     assert account.id == friend_id
 
-    result = run_json("unmute", friend.username, "--json")
+    result = run_json(cli.accounts.unmute, friend.username, "--json")
     relationship = from_dict(Relationship, result)
     assert relationship.id == friend_id
     assert relationship.muting is False
 
-    result = run_json("muted", "--json")
+    result = run_json(cli.accounts.muted, "--json")
     assert result == []
 
 
@@ -167,52 +204,71 @@ def test_block(app, user, friend, friend_id, run):
     # Make sure we're not initially blocking friend
     api.unblock(app, user, friend_id)
 
-    out = run("blocked")
+    result = run(cli.accounts.blocked)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == "No accounts blocked"
 
-    out = run("block", friend.username)
+    result = run(cli.accounts.block, friend.username)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == f"✓ You are now blocking {friend.username}"
 
-    out = run("blocked")
+    result = run(cli.accounts.blocked)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert friend.username in out
 
-    out = run("unblock", friend.username)
+    result = run(cli.accounts.unblock, friend.username)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == f"✓ {friend.username} is no longer blocked"
 
-    out = run("blocked")
+    result = run(cli.accounts.blocked)
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == "No accounts blocked"
 
 
 def test_block_case_insensitive(friend: User, run):
-    out = run("block", friend.username.upper())
+    result = run(cli.accounts.block, friend.username.upper())
+    assert result.exit_code == 0
+
+    out = result.stdout.strip()
     assert out == f"✓ You are now blocking {friend.username.upper()}"
 
 
 def test_block_not_found(run):
-    out = run("block", "doesnotexistperson")
-    assert out == f"Account not found"
+    result = run(cli.accounts.block, "doesnotexistperson")
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Account not found"
 
 
 def test_block_json(app: App, user: User, friend: User, run_json, friend_id):
     # Make sure we're not initially blocking friend
     api.unblock(app, user, friend_id)
 
-    result = run_json("blocked", "--json")
+    result = run_json(cli.accounts.blocked, "--json")
     assert result == []
 
-    result = run_json("block", friend.username, "--json")
+    result = run_json(cli.accounts.block, friend.username, "--json")
     relationship = from_dict(Relationship, result)
     assert relationship.id == friend_id
     assert relationship.blocking is True
 
-    [result] = run_json("blocked", "--json")
+    [result] = run_json(cli.accounts.blocked, "--json")
     account = from_dict(Account, result)
     assert account.id == friend_id
 
-    result = run_json("unblock", friend.username, "--json")
+    result = run_json(cli.accounts.unblock, friend.username, "--json")
     relationship = from_dict(Relationship, result)
     assert relationship.id == friend_id
     assert relationship.blocking is False
 
-    result = run_json("blocked", "--json")
+    result = run_json(cli.accounts.blocked, "--json")
     assert result == []
diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py
index 6720f8b5..858d6fbc 100644
--- a/tests/integration/test_auth.py
+++ b/tests/integration/test_auth.py
@@ -1,129 +1,217 @@
-from tests.integration.conftest import TRUMPET
-from toot import api
-from toot.entities import Account, from_dict
-from toot.utils import get_text
+from typing import Any, Dict
+from unittest import mock
+from unittest.mock import MagicMock
+
+from toot import User, cli
+from toot.cli import Run
+
+# TODO: figure out how to test login
+
+
+EMPTY_CONFIG: Dict[Any, Any] = {
+    "apps": {},
+    "users": {},
+    "active_user": None
+}
+
+SAMPLE_CONFIG = {
+    "active_user": "frank@foo.social",
+    "apps": {
+        "foo.social": {
+            "base_url": "http://foo.social",
+            "client_id": "123",
+            "client_secret": "123",
+            "instance": "foo.social"
+        },
+        "bar.social": {
+            "base_url": "http://bar.social",
+            "client_id": "123",
+            "client_secret": "123",
+            "instance": "bar.social"
+        },
+    },
+    "users": {
+        "frank@foo.social": {
+            "access_token": "123",
+            "instance": "foo.social",
+            "username": "frank"
+        },
+        "frank@bar.social": {
+            "access_token": "123",
+            "instance": "bar.social",
+            "username": "frank"
+        },
+    }
+}
+
+
+def test_env(run: Run):
+    result = run(cli.auth.env)
+    assert result.exit_code == 0
+    assert "toot" in result.stdout
+    assert "Python" in result.stdout
+
+
+@mock.patch("toot.config.load_config")
+def test_auth_empty(load_config: MagicMock, run: Run):
+    load_config.return_value = EMPTY_CONFIG
+    result = run(cli.auth.auth)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "You are not logged in to any accounts"
+
+
+@mock.patch("toot.config.load_config")
+def test_auth_full(load_config: MagicMock, run: Run):
+    load_config.return_value = SAMPLE_CONFIG
+    result = run(cli.auth.auth)
+    assert result.exit_code == 0
+    assert result.stdout.strip().startswith("Authenticated accounts:")
+    assert "frank@foo.social" in result.stdout
+    assert "frank@bar.social" in result.stdout
+
+
+# Saving config is mocked so we don't mess up our local config
+# TODO: could this be implemented using an auto-use fixture so we have it always
+# mocked?
+@mock.patch("toot.config.load_app")
+@mock.patch("toot.config.save_app")
+@mock.patch("toot.config.save_user")
+def test_login_cli(
+    save_user: MagicMock,
+    save_app: MagicMock,
+    load_app: MagicMock,
+    user: User,
+    run: Run,
+):
+    load_app.return_value = None
+
+    result = run(
+        cli.auth.login_cli,
+        "--instance", "http://localhost:3000",
+        "--email", f"{user.username}@example.com",
+        "--password", "password",
+    )
+    assert result.exit_code == 0
+    assert "✓ Successfully logged in." in result.stdout
+
+    save_app.assert_called_once()
+    (app,) = save_app.call_args.args
+    assert app.instance == "localhost:3000"
+    assert app.base_url == "http://localhost:3000"
+    assert app.client_id
+    assert app.client_secret
+
+    save_user.assert_called_once()
+    (new_user,) = save_user.call_args.args
+    assert new_user.instance == "localhost:3000"
+    assert new_user.username == user.username
+    # access token will be different since this is a new login
+    assert new_user.access_token and new_user.access_token != user.access_token
+    assert save_user.call_args.kwargs == {"activate": True}
+
+
+@mock.patch("toot.config.load_app")
+@mock.patch("toot.config.save_app")
+@mock.patch("toot.config.save_user")
+def test_login_cli_wrong_password(
+    save_user: MagicMock,
+    save_app: MagicMock,
+    load_app: MagicMock,
+    user: User,
+    run: Run,
+):
+    load_app.return_value = None
+
+    result = run(
+        cli.auth.login_cli,
+        "--instance", "http://localhost:3000",
+        "--email", f"{user.username}@example.com",
+        "--password", "wrong password",
+    )
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Login failed"
+
+    save_app.assert_called_once()
+    (app,) = save_app.call_args.args
+    assert app.instance == "localhost:3000"
+    assert app.base_url == "http://localhost:3000"
+    assert app.client_id
+    assert app.client_secret
+
+    save_user.assert_not_called()
+
+
+@mock.patch("toot.config.load_config")
+@mock.patch("toot.config.delete_user")
+def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run):
+    load_config.return_value = SAMPLE_CONFIG
+
+    result = run(cli.auth.logout, "frank@foo.social")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account frank@foo.social logged out"
+    delete_user.assert_called_once_with(User("foo.social", "frank", "123"))
+
+
+@mock.patch("toot.config.load_config")
+def test_logout_not_logged_in(load_config: MagicMock, run: Run):
+    load_config.return_value = EMPTY_CONFIG
+
+    result = run(cli.auth.logout)
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: You're not logged into any accounts"
+
+
+@mock.patch("toot.config.load_config")
+def test_logout_account_not_specified(load_config: MagicMock, run: Run):
+    load_config.return_value = SAMPLE_CONFIG
+
+    result = run(cli.auth.logout)
+    assert result.exit_code == 1
+    assert result.stderr.startswith("Error: Specify account to log out")
+
+
+@mock.patch("toot.config.load_config")
+def test_logout_account_does_not_exist(load_config: MagicMock, run: Run):
+    load_config.return_value = SAMPLE_CONFIG
+
+    result = run(cli.auth.logout, "banana")
+    assert result.exit_code == 1
+    assert result.stderr.startswith("Error: Account not found")
+
+
+@mock.patch("toot.config.load_config")
+@mock.patch("toot.config.activate_user")
+def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run):
+    load_config.return_value = SAMPLE_CONFIG
+
+    result = run(cli.auth.activate, "frank@foo.social")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account frank@foo.social activated"
+    activate_user.assert_called_once_with(User("foo.social", "frank", "123"))
 
 
-def test_update_account_no_options(run):
-    out = run("update_account")
-    assert out == "Please specify at least one option to update the account"
+@mock.patch("toot.config.load_config")
+def test_activate_not_logged_in(load_config: MagicMock, run: Run):
+    load_config.return_value = EMPTY_CONFIG
 
+    result = run(cli.auth.activate)
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: You're not logged into any accounts"
 
-def test_update_account_display_name(run, app, user):
-    out = run("update_account", "--display-name", "elwood")
-    assert out == "✓ Account updated"
 
-    account = api.verify_credentials(app, user).json()
-    assert account["display_name"] == "elwood"
+@mock.patch("toot.config.load_config")
+def test_activate_account_not_given(load_config: MagicMock, run: Run):
+    load_config.return_value = SAMPLE_CONFIG
 
+    result = run(cli.auth.activate)
+    assert result.exit_code == 1
+    assert result.stderr.startswith("Error: Specify account to activate")
 
-def test_update_account_json(run_json, app, user):
-    out = run_json("update_account", "--display-name", "elwood", "--json")
-    account = from_dict(Account, out)
-    assert account.acct == user.username
-    assert account.display_name == "elwood"
 
+@mock.patch("toot.config.load_config")
+def test_activate_invalid_Account(load_config: MagicMock, run: Run):
+    load_config.return_value = SAMPLE_CONFIG
 
-def test_update_account_note(run, app, user):
-    note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack "
-           "of cigarettes, it's dark... and we're wearing sunglasses.")
-
-    out = run("update_account", "--note", note)
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert get_text(account["note"]) == note
-
-
-def test_update_account_language(run, app, user):
-    out = run("update_account", "--language", "hr")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["source"]["language"] == "hr"
-
-
-def test_update_account_privacy(run, app, user):
-    out = run("update_account", "--privacy", "private")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["source"]["privacy"] == "private"
-
-
-def test_update_account_avatar(run, app, user):
-    account = api.verify_credentials(app, user).json()
-    old_value = account["avatar"]
-
-    out = run("update_account", "--avatar", TRUMPET)
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["avatar"] != old_value
-
-
-def test_update_account_header(run, app, user):
-    account = api.verify_credentials(app, user).json()
-    old_value = account["header"]
-
-    out = run("update_account", "--header", TRUMPET)
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["header"] != old_value
-
-
-def test_update_account_locked(run, app, user):
-    out = run("update_account", "--locked")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["locked"] is True
-
-    out = run("update_account", "--no-locked")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["locked"] is False
-
-
-def test_update_account_bot(run, app, user):
-    out = run("update_account", "--bot")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["bot"] is True
-
-    out = run("update_account", "--no-bot")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["bot"] is False
-
-
-def test_update_account_discoverable(run, app, user):
-    out = run("update_account", "--discoverable")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["discoverable"] is True
-
-    out = run("update_account", "--no-discoverable")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["discoverable"] is False
-
-
-def test_update_account_sensitive(run, app, user):
-    out = run("update_account", "--sensitive")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["source"]["sensitive"] is True
-
-    out = run("update_account", "--no-sensitive")
-    assert out == "✓ Account updated"
-
-    account = api.verify_credentials(app, user).json()
-    assert account["source"]["sensitive"] is False
+    result = run(cli.auth.activate, "banana")
+    assert result.exit_code == 1
+    assert result.stderr.startswith("Error: Account not found")
diff --git a/tests/integration/test_lists.py b/tests/integration/test_lists.py
index 6f98998d..21b3f0ba 100644
--- a/tests/integration/test_lists.py
+++ b/tests/integration/test_lists.py
@@ -1,67 +1,86 @@
+from toot import cli
+
 from tests.integration.conftest import register_account
 
 
 def test_lists_empty(run):
-    out = run("lists")
-    assert out == "You have no lists defined."
+    result = run(cli.lists.list)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "You have no lists defined."
 
 
 def test_list_create_delete(run):
-    out = run("list_create", "banana")
-    assert out == '✓ List "banana" created.'
+    result = run(cli.lists.create, "banana")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == '✓ List "banana" created.'
 
-    out = run("lists")
-    assert "banana" in out
+    result = run(cli.lists.list)
+    assert result.exit_code == 0
+    assert "banana" in result.stdout
 
-    out = run("list_create", "mango")
-    assert out == '✓ List "mango" created.'
+    result = run(cli.lists.create, "mango")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == '✓ List "mango" created.'
 
-    out = run("lists")
-    assert "banana" in out
-    assert "mango" in out
+    result = run(cli.lists.list)
+    assert result.exit_code == 0
+    assert "banana" in result.stdout
+    assert "mango" in result.stdout
 
-    out = run("list_delete", "banana")
-    assert out == '✓ List "banana" deleted.'
+    result = run(cli.lists.delete, "banana")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == '✓ List "banana" deleted.'
 
-    out = run("lists")
-    assert "banana" not in out
-    assert "mango" in out
+    result = run(cli.lists.list)
+    assert result.exit_code == 0
+    assert "banana" not in result.stdout
+    assert "mango" in result.stdout
 
-    out = run("list_delete", "mango")
-    assert out == '✓ List "mango" deleted.'
+    result = run(cli.lists.delete, "mango")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == '✓ List "mango" deleted.'
 
-    out = run("lists")
-    assert out == "You have no lists defined."
+    result = run(cli.lists.list)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "You have no lists defined."
 
-    out = run("list_delete", "mango")
-    assert out == "List not found"
+    result = run(cli.lists.delete, "mango")
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: List not found"
 
 
 def test_list_add_remove(run, app):
     acc = register_account(app)
-    run("list_create", "foo")
+    run(cli.lists.create, "foo")
 
-    out = run("list_add", "foo", acc.username)
-    assert out == f"You must follow @{acc.username} before adding this account to a list."
+    result = run(cli.lists.add, "foo", acc.username)
+    assert result.exit_code == 1
+    assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list."
 
-    run("follow", acc.username)
+    run(cli.accounts.follow, acc.username)
 
-    out = run("list_add", "foo", acc.username)
-    assert out == f'✓ Added account "{acc.username}"'
+    result = run(cli.lists.add, "foo", acc.username)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == f'✓ Added account "{acc.username}"'
 
-    out = run("list_accounts", "foo")
-    assert acc.username in out
+    result = run(cli.lists.accounts, "foo")
+    assert result.exit_code == 0
+    assert acc.username in result.stdout
 
     # Account doesn't exist
-    out = run("list_add", "foo", "does_not_exist")
-    assert out == "Account not found"
+    result = run(cli.lists.add, "foo", "does_not_exist")
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Account not found"
 
     # List doesn't exist
-    out = run("list_add", "does_not_exist", acc.username)
-    assert out == "List not found"
+    result = run(cli.lists.add, "does_not_exist", acc.username)
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: List not found"
 
-    out = run("list_remove", "foo", acc.username)
-    assert out == f'✓ Removed account "{acc.username}"'
+    result = run(cli.lists.remove, "foo", acc.username)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == f'✓ Removed account "{acc.username}"'
 
-    out = run("list_accounts", "foo")
-    assert out == "This list has no accounts."
+    result = run(cli.lists.accounts, "foo")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "This list has no accounts."
diff --git a/tests/integration/test_post.py b/tests/integration/test_post.py
index d3f0e058..13226b3d 100644
--- a/tests/integration/test_post.py
+++ b/tests/integration/test_post.py
@@ -5,15 +5,17 @@
 from datetime import datetime, timedelta, timezone
 from os import path
 from tests.integration.conftest import ASSETS_DIR, posted_status_id
-from toot import CLIENT_NAME, CLIENT_WEBSITE, api
+from toot import CLIENT_NAME, CLIENT_WEBSITE, api, cli
 from toot.utils import get_text
 from unittest import mock
 
 
 def test_post(app, user, run):
     text = "i wish i was a #lumberjack"
-    out = run("post", text)
-    status_id = posted_status_id(out)
+    result = run(cli.post.post, text)
+    assert result.exit_code == 0
+
+    status_id = posted_status_id(result.stdout)
 
     status = api.fetch_status(app, user, status_id).json()
     assert text == get_text(status["content"])
@@ -28,11 +30,18 @@ def test_post(app, user, run):
         assert status["application"]["website"] == CLIENT_WEBSITE
 
 
+def test_post_no_text(run):
+    result = run(cli.post.post)
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: You must specify either text or media to post."
+
+
 def test_post_json(run):
     content = "i wish i was a #lumberjack"
-    out = run("post", content, "--json")
-    status = json.loads(out)
+    result = run(cli.post.post, content, "--json")
+    assert result.exit_code == 0
 
+    status = json.loads(result.stdout)
     assert get_text(status["content"]) == content
     assert status["visibility"] == "public"
     assert status["sensitive"] is False
@@ -42,8 +51,10 @@ def test_post_json(run):
 
 def test_post_visibility(app, user, run):
     for visibility in ["public", "unlisted", "private", "direct"]:
-        out = run("post", "foo", "--visibility", visibility)
-        status_id = posted_status_id(out)
+        result = run(cli.post.post, "foo", "--visibility", visibility)
+        assert result.exit_code == 0
+
+        status_id = posted_status_id(result.stdout)
         status = api.fetch_status(app, user, status_id).json()
         assert status["visibility"] == visibility
 
@@ -52,14 +63,23 @@ def test_post_scheduled_at(app, user, run):
     text = str(uuid.uuid4())
     scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10)
 
-    out = run("post", text, "--scheduled-at", scheduled_at.isoformat())
-    assert "Toot scheduled for" in out
+    result = run(cli.post.post, text, "--scheduled-at", scheduled_at.isoformat())
+    assert result.exit_code == 0
+
+    assert "Toot scheduled for" in result.stdout
 
     statuses = api.scheduled_statuses(app, user)
     [status] = [s for s in statuses if s["params"]["text"] == text]
     assert datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%f%z") == scheduled_at
 
 
+def test_post_scheduled_at_error(run):
+    result = run(cli.post.post, "foo", "--scheduled-at", "banana")
+    assert result.exit_code == 1
+    # Stupid error returned by mastodon
+    assert result.stderr.strip() == "Error: Record invalid"
+
+
 def test_post_scheduled_in(app, user, run):
     text = str(uuid.uuid4())
 
@@ -76,9 +96,11 @@ def test_post_scheduled_in(app, user, run):
 
     datetimes = []
     for scheduled_in, delta in variants:
-        out = run("post", text, "--scheduled-in", scheduled_in)
+        result = run(cli.post.post, text, "--scheduled-in", scheduled_in)
+        assert result.exit_code == 0
+
         dttm = datetime.utcnow() + delta
-        assert out.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
+        assert result.stdout.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
         datetimes.append(dttm)
 
     scheduled = api.scheduled_statuses(app, user)
@@ -92,18 +114,31 @@ def test_post_scheduled_in(app, user, run):
         assert delta.total_seconds() < 5
 
 
+def test_post_scheduled_in_invalid_duration(run):
+    result = run(cli.post.post, "foo", "--scheduled-in", "banana")
+    assert result.exit_code == 2
+    assert "Invalid duration: banana" in result.stderr
+
+
+def test_post_scheduled_in_empty_duration(run):
+    result = run(cli.post.post, "foo", "--scheduled-in", "0m")
+    assert result.exit_code == 2
+    assert "Empty duration" in result.stderr
+
+
 def test_post_poll(app, user, run):
     text = str(uuid.uuid4())
 
-    out = run(
-        "post", text,
+    result = run(
+        cli.post.post, text,
         "--poll-option", "foo",
         "--poll-option", "bar",
         "--poll-option", "baz",
         "--poll-option", "qux",
     )
 
-    status_id = posted_status_id(out)
+    assert result.exit_code == 0
+    status_id = posted_status_id(result.stdout)
 
     status = api.fetch_status(app, user, status_id).json()
     assert status["poll"]["expired"] is False
@@ -125,15 +160,15 @@ def test_post_poll(app, user, run):
 def test_post_poll_multiple(app, user, run):
     text = str(uuid.uuid4())
 
-    out = run(
-        "post", text,
+    result = run(
+        cli.post.post, text,
         "--poll-option", "foo",
         "--poll-option", "bar",
         "--poll-multiple"
     )
+    assert result.exit_code == 0
 
-    status_id = posted_status_id(out)
-
+    status_id = posted_status_id(result.stdout)
     status = api.fetch_status(app, user, status_id).json()
     assert status["poll"]["multiple"] is True
 
@@ -141,14 +176,15 @@ def test_post_poll_multiple(app, user, run):
 def test_post_poll_expires_in(app, user, run):
     text = str(uuid.uuid4())
 
-    out = run(
-        "post", text,
+    result = run(
+        cli.post.post, text,
         "--poll-option", "foo",
         "--poll-option", "bar",
         "--poll-expires-in", "8h",
     )
+    assert result.exit_code == 0
 
-    status_id = posted_status_id(out)
+    status_id = posted_status_id(result.stdout)
 
     status = api.fetch_status(app, user, status_id).json()
     actual = datetime.strptime(status["poll"]["expires_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
@@ -160,14 +196,15 @@ def test_post_poll_expires_in(app, user, run):
 def test_post_poll_hide_totals(app, user, run):
     text = str(uuid.uuid4())
 
-    out = run(
-        "post", text,
+    result = run(
+        cli.post.post, text,
         "--poll-option", "foo",
         "--poll-option", "bar",
         "--poll-hide-totals"
     )
+    assert result.exit_code == 0
 
-    status_id = posted_status_id(out)
+    status_id = posted_status_id(result.stdout)
 
     status = api.fetch_status(app, user, status_id).json()
 
@@ -179,30 +216,41 @@ def test_post_poll_hide_totals(app, user, run):
 
 
 def test_post_language(app, user, run):
-    out = run("post", "test", "--language", "hr")
-    status_id = posted_status_id(out)
+    result = run(cli.post.post, "test", "--language", "hr")
+    assert result.exit_code == 0
+
+    status_id = posted_status_id(result.stdout)
     status = api.fetch_status(app, user, status_id).json()
     assert status["language"] == "hr"
 
-    out = run("post", "test", "--language", "zh")
-    status_id = posted_status_id(out)
+    result = run(cli.post.post, "test", "--language", "zh")
+    assert result.exit_code == 0
+
+    status_id = posted_status_id(result.stdout)
     status = api.fetch_status(app, user, status_id).json()
     assert status["language"] == "zh"
 
 
+def test_post_language_error(run):
+    result = run(cli.post.post, "test", "--language", "banana")
+    assert result.exit_code == 2
+    assert "Language should be a two letter abbreviation." in result.stderr
+
+
 def test_media_thumbnail(app, user, run):
     video_path = path.join(ASSETS_DIR, "small.webm")
     thumbnail_path = path.join(ASSETS_DIR, "test1.png")
 
-    out = run(
-        "post",
+    result = run(
+        cli.post.post,
         "--media", video_path,
         "--thumbnail", thumbnail_path,
         "--description", "foo",
         "some text"
     )
+    assert result.exit_code == 0
 
-    status_id = posted_status_id(out)
+    status_id = posted_status_id(result.stdout)
     status = api.fetch_status(app, user, status_id).json()
     [media] = status["media_attachments"]
 
@@ -227,8 +275,8 @@ def test_media_attachments(app, user, run):
     path3 = path.join(ASSETS_DIR, "test3.png")
     path4 = path.join(ASSETS_DIR, "test4.png")
 
-    out = run(
-        "post",
+    result = run(
+        cli.post.post,
         "--media", path1,
         "--media", path2,
         "--media", path3,
@@ -239,8 +287,9 @@ def test_media_attachments(app, user, run):
         "--description", "Test 4",
         "some text"
     )
+    assert result.exit_code == 0
 
-    status_id = posted_status_id(out)
+    status_id = posted_status_id(result.stdout)
     status = api.fetch_status(app, user, status_id).json()
 
     [a1, a2, a3, a4] = status["media_attachments"]
@@ -258,6 +307,13 @@ def test_media_attachments(app, user, run):
     assert a4["description"] == "Test 4"
 
 
+def test_too_many_media(run):
+    m = path.join(ASSETS_DIR, "test1.png")
+    result = run(cli.post.post, "-m", m, "-m", m, "-m", m, "-m", m, "-m", m)
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Cannot attach more than 4 files."
+
+
 @mock.patch("toot.utils.multiline_input")
 @mock.patch("sys.stdin.read")
 def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
@@ -267,8 +323,10 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
 
     media_path = path.join(ASSETS_DIR, "test1.png")
 
-    out = run("post", "--media", media_path)
-    status_id = posted_status_id(out)
+    result = run(cli.post.post, "--media", media_path)
+    assert result.exit_code == 0
+
+    status_id = posted_status_id(result.stdout)
 
     status = api.fetch_status(app, user, status_id).json()
     assert status["content"] == ""
@@ -284,14 +342,18 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
 def test_reply_thread(app, user, friend, run):
     status = api.post_status(app, friend, "This is the status").json()
 
-    out = run("post", "--reply-to", status["id"], "This is the reply")
-    status_id = posted_status_id(out)
+    result = run(cli.post.post, "--reply-to", status["id"], "This is the reply")
+    assert result.exit_code == 0
+
+    status_id = posted_status_id(result.stdout)
     reply = api.fetch_status(app, user, status_id).json()
 
     assert reply["in_reply_to_id"] == status["id"]
 
-    out = run("thread", status["id"])
-    [s1, s2] = [s.strip() for s in re.split(r"─+", out) if s.strip()]
+    result = run(cli.read.thread, status["id"])
+    assert result.exit_code == 0
+
+    [s1, s2] = [s.strip() for s in re.split(r"─+", result.stdout) if s.strip()]
 
     assert "This is the status" in s1
     assert "This is the reply" in s2
diff --git a/tests/integration/test_read.py b/tests/integration/test_read.py
index 67e77836..5c9e4bbb 100644
--- a/tests/integration/test_read.py
+++ b/tests/integration/test_read.py
@@ -1,45 +1,68 @@
 import json
-from pprint import pprint
-import pytest
 import re
 
-from toot import api
-from toot.entities import Account, from_dict_list
-from toot.exceptions import ConsoleError
+from tests.integration.conftest import TOOT_TEST_BASE_URL
+from toot import api, cli
+from toot.entities import Account, Status, from_dict, from_dict_list
 from uuid import uuid4
 
 
-def test_instance(app, run):
-    out = run("instance", "--disable-https")
-    assert "Mastodon" in out
-    assert app.instance in out
-    assert "running Mastodon" in out
+def test_instance_default(app, run):
+    result = run(cli.read.instance)
+    assert result.exit_code == 0
+
+    assert "Mastodon" in result.stdout
+    assert app.instance in result.stdout
+    assert "running Mastodon" in result.stdout
+
+
+def test_instance_with_url(app, run):
+    result = run(cli.read.instance, TOOT_TEST_BASE_URL)
+    assert result.exit_code == 0
+
+    assert "Mastodon" in result.stdout
+    assert app.instance in result.stdout
+    assert "running Mastodon" in result.stdout
 
 
 def test_instance_json(app, run):
-    out = run("instance", "--json")
-    data = json.loads(out)
+    result = run(cli.read.instance, "--json")
+    assert result.exit_code == 0
+
+    data = json.loads(result.stdout)
     assert data["title"] is not None
     assert data["description"] is not None
     assert data["version"] is not None
 
 
 def test_instance_anon(app, run_anon, base_url):
-    out = run_anon("instance", base_url)
-    assert "Mastodon" in out
-    assert app.instance in out
-    assert "running Mastodon" in out
+    result = run_anon(cli.read.instance, base_url)
+    assert result.exit_code == 0
+
+    assert "Mastodon" in result.stdout
+    assert app.instance in result.stdout
+    assert "running Mastodon" in result.stdout
 
     # Need to specify the instance name when running anon
-    with pytest.raises(ConsoleError) as exc:
-        run_anon("instance")
-    assert str(exc.value) == "Please specify an instance."
+    result = run_anon(cli.read.instance)
+    assert result.exit_code == 1
+    assert result.stderr == "Error: Please specify an instance.\n"
 
 
 def test_whoami(user, run):
-    out = run("whoami")
-    # TODO: test other fields once updating account is supported
-    assert f"@{user.username}" in out
+    result = run(cli.read.whoami)
+    assert result.exit_code == 0
+    assert f"@{user.username}" in result.stdout
+
+
+def test_whoami_json(user, run):
+    result = run(cli.read.whoami, "--json")
+    assert result.exit_code == 0
+
+    data = json.loads(result.stdout)
+    account = from_dict(Account, data)
+    assert account.username == user.username
+    assert account.acct == user.username
 
 
 def test_whois(app, friend, run):
@@ -51,18 +74,33 @@ def test_whois(app, friend, run):
     ]
 
     for username in variants:
-        out = run("whois", username)
-        assert f"@{friend.username}" in out
+        result = run(cli.read.whois, username)
+        assert result.exit_code == 0
+        assert f"@{friend.username}" in result.stdout
+
+
+def test_whois_json(app, friend, run):
+    result = run(cli.read.whois, friend.username, "--json")
+    assert result.exit_code == 0
+
+    data = json.loads(result.stdout)
+    account = from_dict(Account, data)
+    assert account.username == friend.username
+    assert account.acct == friend.username
 
 
 def test_search_account(friend, run):
-    out = run("search", friend.username)
-    assert out == f"Accounts:\n* @{friend.username}"
+    result = run(cli.read.search, friend.username)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == f"Accounts:\n* @{friend.username}"
+
 
+def test_search_account_json(friend, run):
+    result = run(cli.read.search, friend.username, "--json")
+    assert result.exit_code == 0
 
-def test_search_account_json(friend, run_json):
-    out = run_json("search", friend.username, "--json")
-    [account] = from_dict_list(Account, out["accounts"])
+    data = json.loads(result.stdout)
+    [account] = from_dict_list(Account, data["accounts"])
     assert account.acct == friend.username
 
 
@@ -71,68 +109,67 @@ def test_search_hashtag(app, user, run):
     api.post_status(app, user, "#hashtag_y")
     api.post_status(app, user, "#hashtag_z")
 
-    out = run("search", "#hashtag")
-    assert out == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
+    result = run(cli.read.search, "#hashtag")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
 
 
-def test_search_hashtag_json(app, user, run_json):
+def test_search_hashtag_json(app, user, run):
     api.post_status(app, user, "#hashtag_x")
     api.post_status(app, user, "#hashtag_y")
     api.post_status(app, user, "#hashtag_z")
 
-    out = run_json("search", "#hashtag", "--json")
-    [h1, h2, h3] = sorted(out["hashtags"], key=lambda h: h["name"])
+    result = run(cli.read.search, "#hashtag", "--json")
+    assert result.exit_code == 0
+
+    data = json.loads(result.stdout)
+    [h1, h2, h3] = sorted(data["hashtags"], key=lambda h: h["name"])
 
     assert h1["name"] == "hashtag_x"
     assert h2["name"] == "hashtag_y"
     assert h3["name"] == "hashtag_z"
 
 
-def test_tags(run, base_url):
-    out = run("tags_followed")
-    assert out == "You're not following any hashtags."
-
-    out = run("tags_follow", "foo")
-    assert out == "✓ You are now following #foo"
-
-    out = run("tags_followed")
-    assert out == f"* #foo\t{base_url}/tags/foo"
-
-    out = run("tags_follow", "bar")
-    assert out == "✓ You are now following #bar"
-
-    out = run("tags_followed")
-    assert out == "\n".join([
-        f"* #bar\t{base_url}/tags/bar",
-        f"* #foo\t{base_url}/tags/foo",
-    ])
+def test_status(app, user, run):
+    uuid = str(uuid4())
+    status_id = api.post_status(app, user, uuid).json()["id"]
 
-    out = run("tags_unfollow", "foo")
-    assert out == "✓ You are no longer following #foo"
+    result = run(cli.read.status, status_id)
+    assert result.exit_code == 0
 
-    out = run("tags_followed")
-    assert out == f"* #bar\t{base_url}/tags/bar"
+    out = result.stdout.strip()
+    assert uuid in out
+    assert user.username in out
+    assert status_id in out
 
 
-def test_status(app, user, run):
+def test_status_json(app, user, run):
     uuid = str(uuid4())
-    response = api.post_status(app, user, uuid).json()
+    status_id = api.post_status(app, user, uuid).json()["id"]
 
-    out = run("status", response["id"])
-    assert uuid in out
-    assert user.username in out
-    assert response["id"] in out
+    result = run(cli.read.status, status_id, "--json")
+    assert result.exit_code == 0
+
+    status = from_dict(Status, json.loads(result.stdout))
+    assert status.id == status_id
+    assert status.account.acct == user.username
+    assert uuid in status.content
 
 
 def test_thread(app, user, run):
-    uuid = str(uuid4())
-    s1 = api.post_status(app, user, uuid + "1").json()
-    s2 = api.post_status(app, user, uuid + "2", in_reply_to_id=s1["id"]).json()
-    s3 = api.post_status(app, user, uuid + "3", in_reply_to_id=s2["id"]).json()
+    uuid1 = str(uuid4())
+    uuid2 = str(uuid4())
+    uuid3 = str(uuid4())
+
+    s1 = api.post_status(app, user, uuid1).json()
+    s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
+    s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
 
     for status in [s1, s2, s3]:
-        out = run("thread", status["id"])
-        bits = re.split(r"─+", out)
+        result = run(cli.read.thread, status["id"])
+        assert result.exit_code == 0
+
+        bits = re.split(r"─+", result.stdout.strip())
         bits = [b for b in bits if b]
 
         assert len(bits) == 3
@@ -141,6 +178,26 @@ def test_thread(app, user, run):
         assert s2["id"] in bits[1]
         assert s3["id"] in bits[2]
 
-        assert f"{uuid}1" in bits[0]
-        assert f"{uuid}2" in bits[1]
-        assert f"{uuid}3" in bits[2]
+        assert uuid1 in bits[0]
+        assert uuid2 in bits[1]
+        assert uuid3 in bits[2]
+
+
+def test_thread_json(app, user, run):
+    uuid1 = str(uuid4())
+    uuid2 = str(uuid4())
+    uuid3 = str(uuid4())
+
+    s1 = api.post_status(app, user, uuid1).json()
+    s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
+    s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
+
+    result = run(cli.read.thread, s2["id"], "--json")
+    assert result.exit_code == 0
+
+    result = json.loads(result.stdout)
+    [ancestor] = [from_dict(Status, s) for s in result["ancestors"]]
+    [descendent] = [from_dict(Status, s) for s in result["descendants"]]
+
+    assert ancestor.id == s1["id"]
+    assert descendent.id == s3["id"]
diff --git a/tests/integration/test_status.py b/tests/integration/test_status.py
index 3daf65e7..6f9a2a4c 100644
--- a/tests/integration/test_status.py
+++ b/tests/integration/test_status.py
@@ -2,15 +2,16 @@
 import time
 import pytest
 
-from toot import api
+from toot import api, cli
 from toot.exceptions import NotFoundError
 
 
 def test_delete(app, user, run):
     status = api.post_status(app, user, "foo").json()
 
-    out = run("delete", status["id"])
-    assert out == "✓ Status deleted"
+    result = run(cli.statuses.delete, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Status deleted"
 
     with pytest.raises(NotFoundError):
         api.fetch_status(app, user, status["id"])
@@ -19,7 +20,10 @@ def test_delete(app, user, run):
 def test_delete_json(app, user, run):
     status = api.post_status(app, user, "foo").json()
 
-    out = run("delete", status["id"], "--json")
+    result = run(cli.statuses.delete, status["id"], "--json")
+    assert result.exit_code == 0
+
+    out = result.stdout
     result = json.loads(out)
     assert result["id"] == status["id"]
 
@@ -31,17 +35,19 @@ def test_favourite(app, user, run):
     status = api.post_status(app, user, "foo").json()
     assert not status["favourited"]
 
-    out = run("favourite", status["id"])
-    assert out == "✓ Status favourited"
+    result = run(cli.statuses.favourite, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Status favourited"
 
     status = api.fetch_status(app, user, status["id"]).json()
     assert status["favourited"]
 
-    out = run("unfavourite", status["id"])
-    assert out == "✓ Status unfavourited"
+    result = run(cli.statuses.unfavourite, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Status unfavourited"
 
     # A short delay is required before the server returns new data
-    time.sleep(0.1)
+    time.sleep(0.2)
 
     status = api.fetch_status(app, user, status["id"]).json()
     assert not status["favourited"]
@@ -51,15 +57,17 @@ def test_favourite_json(app, user, run):
     status = api.post_status(app, user, "foo").json()
     assert not status["favourited"]
 
-    out = run("favourite", status["id"], "--json")
-    result = json.loads(out)
+    result = run(cli.statuses.favourite, status["id"], "--json")
+    assert result.exit_code == 0
 
+    result = json.loads(result.stdout)
     assert result["id"] == status["id"]
     assert result["favourited"] is True
 
-    out = run("unfavourite", status["id"], "--json")
-    result = json.loads(out)
+    result = run(cli.statuses.unfavourite, status["id"], "--json")
+    assert result.exit_code == 0
 
+    result = json.loads(result.stdout)
     assert result["id"] == status["id"]
     assert result["favourited"] is False
 
@@ -68,17 +76,24 @@ def test_reblog(app, user, run):
     status = api.post_status(app, user, "foo").json()
     assert not status["reblogged"]
 
-    out = run("reblog", status["id"])
-    assert out == "✓ Status reblogged"
+    result = run(cli.statuses.reblogged_by, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "This status is not reblogged by anyone"
+
+    result = run(cli.statuses.reblog, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Status reblogged"
 
     status = api.fetch_status(app, user, status["id"]).json()
     assert status["reblogged"]
 
-    out = run("reblogged_by", status["id"])
-    assert user.username in out
+    result = run(cli.statuses.reblogged_by, status["id"])
+    assert result.exit_code == 0
+    assert user.username in result.stdout
 
-    out = run("unreblog", status["id"])
-    assert out == "✓ Status unreblogged"
+    result = run(cli.statuses.unreblog, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Status unreblogged"
 
     status = api.fetch_status(app, user, status["id"]).json()
     assert not status["reblogged"]
@@ -88,19 +103,23 @@ def test_reblog_json(app, user, run):
     status = api.post_status(app, user, "foo").json()
     assert not status["reblogged"]
 
-    out = run("reblog", status["id"], "--json")
-    result = json.loads(out)
+    result = run(cli.statuses.reblog, status["id"], "--json")
+    assert result.exit_code == 0
 
+    result = json.loads(result.stdout)
     assert result["reblogged"] is True
     assert result["reblog"]["id"] == status["id"]
 
-    out = run("reblogged_by", status["id"], "--json")
-    [reblog] = json.loads(out)
+    result = run(cli.statuses.reblogged_by, status["id"], "--json")
+    assert result.exit_code == 0
+
+    [reblog] = json.loads(result.stdout)
     assert reblog["acct"] == user.username
 
-    out = run("unreblog", status["id"], "--json")
-    result = json.loads(out)
+    result = run(cli.statuses.unreblog, status["id"], "--json")
+    assert result.exit_code == 0
 
+    result = json.loads(result.stdout)
     assert result["reblogged"] is False
     assert result["reblog"] is None
 
@@ -109,14 +128,16 @@ def test_pin(app, user, run):
     status = api.post_status(app, user, "foo").json()
     assert not status["pinned"]
 
-    out = run("pin", status["id"])
-    assert out == "✓ Status pinned"
+    result = run(cli.statuses.pin, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Status pinned"
 
     status = api.fetch_status(app, user, status["id"]).json()
     assert status["pinned"]
 
-    out = run("unpin", status["id"])
-    assert out == "✓ Status unpinned"
+    result = run(cli.statuses.unpin, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Status unpinned"
 
     status = api.fetch_status(app, user, status["id"]).json()
     assert not status["pinned"]
@@ -126,15 +147,17 @@ def test_pin_json(app, user, run):
     status = api.post_status(app, user, "foo").json()
     assert not status["pinned"]
 
-    out = run("pin", status["id"], "--json")
-    result = json.loads(out)
+    result = run(cli.statuses.pin, status["id"], "--json")
+    assert result.exit_code == 0
 
+    result = json.loads(result.stdout)
     assert result["pinned"] is True
     assert result["id"] == status["id"]
 
-    out = run("unpin", status["id"], "--json")
-    result = json.loads(out)
+    result = run(cli.statuses.unpin, status["id"], "--json")
+    assert result.exit_code == 0
 
+    result = json.loads(result.stdout)
     assert result["pinned"] is False
     assert result["id"] == status["id"]
 
@@ -143,14 +166,16 @@ def test_bookmark(app, user, run):
     status = api.post_status(app, user, "foo").json()
     assert not status["bookmarked"]
 
-    out = run("bookmark", status["id"])
-    assert out == "✓ Status bookmarked"
+    result = run(cli.statuses.bookmark, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Status bookmarked"
 
     status = api.fetch_status(app, user, status["id"]).json()
     assert status["bookmarked"]
 
-    out = run("unbookmark", status["id"])
-    assert out == "✓ Status unbookmarked"
+    result = run(cli.statuses.unbookmark, status["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Status unbookmarked"
 
     status = api.fetch_status(app, user, status["id"]).json()
     assert not status["bookmarked"]
@@ -160,14 +185,16 @@ def test_bookmark_json(app, user, run):
     status = api.post_status(app, user, "foo").json()
     assert not status["bookmarked"]
 
-    out = run("bookmark", status["id"], "--json")
-    result = json.loads(out)
+    result = run(cli.statuses.bookmark, status["id"], "--json")
+    assert result.exit_code == 0
 
+    result = json.loads(result.stdout)
     assert result["id"] == status["id"]
     assert result["bookmarked"] is True
 
-    out = run("unbookmark", status["id"], "--json")
-    result = json.loads(out)
+    result = run(cli.statuses.unbookmark, status["id"], "--json")
+    assert result.exit_code == 0
 
+    result = json.loads(result.stdout)
     assert result["id"] == status["id"]
     assert result["bookmarked"] is False
diff --git a/tests/integration/test_tags.py b/tests/integration/test_tags.py
new file mode 100644
index 00000000..9df84ced
--- /dev/null
+++ b/tests/integration/test_tags.py
@@ -0,0 +1,163 @@
+import re
+from typing import List
+
+from toot import api, cli
+from toot.entities import FeaturedTag, Tag, from_dict, from_dict_list
+
+
+def test_tags(run):
+    result = run(cli.tags.tags, "followed")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "You're not following any hashtags"
+
+    result = run(cli.tags.tags, "follow", "foo")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ You are now following #foo"
+
+    result = run(cli.tags.tags, "followed")
+    assert result.exit_code == 0
+    assert _find_tags(result.stdout) == ["#foo"]
+
+    result = run(cli.tags.tags, "follow", "bar")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ You are now following #bar"
+
+    result = run(cli.tags.tags, "followed")
+    assert result.exit_code == 0
+    assert _find_tags(result.stdout) == ["#bar", "#foo"]
+
+    result = run(cli.tags.tags, "unfollow", "foo")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ You are no longer following #foo"
+
+    result = run(cli.tags.tags, "followed")
+    assert result.exit_code == 0
+    assert _find_tags(result.stdout) == ["#bar"]
+
+    result = run(cli.tags.tags, "unfollow", "bar")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ You are no longer following #bar"
+
+    result = run(cli.tags.tags, "followed")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "You're not following any hashtags"
+
+
+def test_tags_json(run_json):
+    result = run_json(cli.tags.tags, "followed", "--json")
+    assert result == []
+
+    result = run_json(cli.tags.tags, "follow", "foo", "--json")
+    tag = from_dict(Tag, result)
+    assert tag.name == "foo"
+    assert tag.following is True
+
+    result = run_json(cli.tags.tags, "followed", "--json")
+    [tag] = from_dict_list(Tag, result)
+    assert tag.name == "foo"
+    assert tag.following is True
+
+    result = run_json(cli.tags.tags, "follow", "bar", "--json")
+    tag = from_dict(Tag, result)
+    assert tag.name == "bar"
+    assert tag.following is True
+
+    result = run_json(cli.tags.tags, "followed", "--json")
+    tags = from_dict_list(Tag, result)
+    [bar, foo] = sorted(tags, key=lambda t: t.name)
+    assert foo.name == "foo"
+    assert foo.following is True
+    assert bar.name == "bar"
+    assert bar.following is True
+
+    result = run_json(cli.tags.tags, "unfollow", "foo", "--json")
+    tag = from_dict(Tag, result)
+    assert tag.name == "foo"
+    assert tag.following is False
+
+    result = run_json(cli.tags.tags, "unfollow", "bar", "--json")
+    tag = from_dict(Tag, result)
+    assert tag.name == "bar"
+    assert tag.following is False
+
+    result = run_json(cli.tags.tags, "followed", "--json")
+    assert result == []
+
+
+def test_tags_featured(run, app, user):
+    result = run(cli.tags.tags, "featured")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "You don't have any featured hashtags"
+
+    result = run(cli.tags.tags, "feature", "foo")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Tag #foo is now featured"
+
+    result = run(cli.tags.tags, "featured")
+    assert result.exit_code == 0
+    assert _find_tags(result.stdout) == ["#foo"]
+
+    result = run(cli.tags.tags, "feature", "bar")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Tag #bar is now featured"
+
+    result = run(cli.tags.tags, "featured")
+    assert result.exit_code == 0
+    assert _find_tags(result.stdout) == ["#bar", "#foo"]
+
+    # Unfeature by Name
+    result = run(cli.tags.tags, "unfeature", "foo")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Tag #foo is no longer featured"
+
+    result = run(cli.tags.tags, "featured")
+    assert result.exit_code == 0
+    assert _find_tags(result.stdout) == ["#bar"]
+
+    # Unfeature by ID
+    tag = api.find_featured_tag(app, user, "bar")
+    assert tag is not None
+
+    result = run(cli.tags.tags, "unfeature", tag["id"])
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Tag #bar is no longer featured"
+
+    result = run(cli.tags.tags, "featured")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "You don't have any featured hashtags"
+
+
+def test_tags_featured_json(run_json):
+    result = run_json(cli.tags.tags, "featured", "--json")
+    assert result == []
+
+    result = run_json(cli.tags.tags, "feature", "foo", "--json")
+    tag = from_dict(FeaturedTag, result)
+    assert tag.name == "foo"
+
+    result = run_json(cli.tags.tags, "featured", "--json")
+    [tag] = from_dict_list(FeaturedTag, result)
+    assert tag.name == "foo"
+
+    result = run_json(cli.tags.tags, "feature", "bar", "--json")
+    tag = from_dict(FeaturedTag, result)
+    assert tag.name == "bar"
+
+    result = run_json(cli.tags.tags, "featured", "--json")
+    tags = from_dict_list(FeaturedTag, result)
+    [bar, foo] = sorted(tags, key=lambda t: t.name)
+    assert foo.name == "foo"
+    assert bar.name == "bar"
+
+    result = run_json(cli.tags.tags, "unfeature", "foo", "--json")
+    assert result == {}
+
+    result = run_json(cli.tags.tags, "unfeature", "bar", "--json")
+    assert result == {}
+
+    result = run_json(cli.tags.tags, "featured", "--json")
+    assert result == []
+
+
+def _find_tags(txt: str) -> List[str]:
+    return sorted(re.findall(r"#\w+", txt))
diff --git a/tests/integration/test_timelines.py b/tests/integration/test_timelines.py
new file mode 100644
index 00000000..8823f695
--- /dev/null
+++ b/tests/integration/test_timelines.py
@@ -0,0 +1,198 @@
+import pytest
+
+from time import sleep
+from uuid import uuid4
+
+from toot import api, cli
+from toot.entities import from_dict, Status
+from tests.integration.conftest import TOOT_TEST_BASE_URL, register_account
+
+
+# TODO: If fixture is not overriden here, tests fail, not sure why, figure it out
+@pytest.fixture(scope="module")
+def user(app):
+    return register_account(app)
+
+
+@pytest.fixture(scope="module")
+def other_user(app):
+    return register_account(app)
+
+
+@pytest.fixture(scope="module")
+def friend_user(app, user):
+    friend = register_account(app)
+    friend_account = api.find_account(app, user, friend.username)
+    api.follow(app, user, friend_account["id"])
+    return friend
+
+
+@pytest.fixture(scope="module")
+def friend_list(app, user, friend_user):
+    friend_account = api.find_account(app, user, friend_user.username)
+    list = api.create_list(app, user, str(uuid4()))
+    api.add_accounts_to_list(app, user, list["id"], account_ids=[friend_account["id"]])
+    return list
+
+
+def test_timelines(app, user, other_user, friend_user, friend_list, run):
+    status1 = _post_status(app, user, "#foo")
+    status2 = _post_status(app, other_user, "#bar")
+    status3 = _post_status(app, friend_user, "#foo #bar")
+
+    # Give mastodon time to process things :/
+    # Tests fail if this is removed, required delay depends on server speed
+    sleep(1)
+
+    # Home timeline
+    result = run(cli.timelines.timeline)
+    assert result.exit_code == 0
+    assert status1.id in result.stdout
+    assert status2.id not in result.stdout
+    assert status3.id in result.stdout
+
+    # Public timeline
+    result = run(cli.timelines.timeline, "--public")
+    assert result.exit_code == 0
+    assert status1.id in result.stdout
+    assert status2.id in result.stdout
+    assert status3.id in result.stdout
+
+    # Anon public timeline
+    result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL, "--public")
+    assert result.exit_code == 0
+    assert status1.id in result.stdout
+    assert status2.id in result.stdout
+    assert status3.id in result.stdout
+
+    # Tag timeline
+    result = run(cli.timelines.timeline, "--tag", "foo")
+    assert result.exit_code == 0
+    assert status1.id in result.stdout
+    assert status2.id not in result.stdout
+    assert status3.id in result.stdout
+
+    result = run(cli.timelines.timeline, "--tag", "bar")
+    assert result.exit_code == 0
+    assert status1.id not in result.stdout
+    assert status2.id in result.stdout
+    assert status3.id in result.stdout
+
+    # Anon tag timeline
+    result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL, "--tag", "foo")
+    assert result.exit_code == 0
+    assert status1.id in result.stdout
+    assert status2.id not in result.stdout
+    assert status3.id in result.stdout
+
+    # List timeline (by list name)
+    result = run(cli.timelines.timeline, "--list", friend_list["title"])
+    assert result.exit_code == 0
+    assert status1.id not in result.stdout
+    assert status2.id not in result.stdout
+    assert status3.id in result.stdout
+
+    # List timeline (by list ID)
+    result = run(cli.timelines.timeline, "--list", friend_list["id"])
+    assert result.exit_code == 0
+    assert status1.id not in result.stdout
+    assert status2.id not in result.stdout
+    assert status3.id in result.stdout
+
+    # Account timeline
+    result = run(cli.timelines.timeline, "--account", friend_user.username)
+    assert result.exit_code == 0
+    assert status1.id not in result.stdout
+    assert status2.id not in result.stdout
+    assert status3.id in result.stdout
+
+    result = run(cli.timelines.timeline, "--account", other_user.username)
+    assert result.exit_code == 0
+    assert status1.id not in result.stdout
+    assert status2.id in result.stdout
+    assert status3.id not in result.stdout
+
+
+def test_empty_timeline(app, run_as):
+    user = register_account(app)
+    result = run_as(user, cli.timelines.timeline)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "─" * 80
+
+
+def test_timeline_cant_combine_timelines(run):
+    result = run(cli.timelines.timeline, "--tag", "foo", "--account", "bar")
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Only one of --public, --tag, --account, or --list can be used at one time."
+
+
+def test_timeline_local_needs_public_or_tag(run):
+    result = run(cli.timelines.timeline, "--local")
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: The --local option is only valid alongside --public or --tag."
+
+
+def test_timeline_instance_needs_public_or_tag(run):
+    result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL)
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: The --instance option is only valid alongside --public or --tag."
+
+
+def test_bookmarks(app, user, run):
+    status1 = _post_status(app, user)
+    status2 = _post_status(app, user)
+
+    api.bookmark(app, user, status1.id)
+    api.bookmark(app, user, status2.id)
+
+    result = run(cli.timelines.bookmarks)
+    assert result.exit_code == 0
+    assert status1.id in result.stdout
+    assert status2.id in result.stdout
+    assert result.stdout.find(status1.id) > result.stdout.find(status2.id)
+
+
+    result = run(cli.timelines.bookmarks, "--reverse")
+    assert result.exit_code == 0
+    assert status1.id in result.stdout
+    assert status2.id in result.stdout
+    assert result.stdout.find(status1.id) < result.stdout.find(status2.id)
+
+
+def test_notifications(app, user, other_user, run):
+    result = run(cli.timelines.notifications)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "You have no notifications"
+
+    text = f"Paging doctor @{user.username}"
+    status = _post_status(app, other_user, text)
+    sleep(0.5)  # grr
+
+    result = run(cli.timelines.notifications)
+    assert result.exit_code == 0
+    assert f"@{other_user.username} mentioned you" in result.stdout
+    assert status.id in result.stdout
+    assert text in result.stdout
+
+    result = run(cli.timelines.notifications, "--mentions")
+    assert result.exit_code == 0
+    assert f"@{other_user.username} mentioned you" in result.stdout
+    assert status.id in result.stdout
+    assert text in result.stdout
+
+
+def test_notifications_follow(app, user, friend_user, run_as):
+    result = run_as(friend_user, cli.timelines.notifications)
+    assert result.exit_code == 0
+    assert f"@{user.username} now follows you" in result.stdout
+
+
+    result = run_as(friend_user, cli.timelines.notifications, "--mentions")
+    assert result.exit_code == 0
+    assert "now follows you" not in result.stdout
+
+
+def _post_status(app, user, text=None) -> Status:
+    text = text or str(uuid4())
+    response = api.post_status(app, user, text)
+    return from_dict(Status, response.json())
diff --git a/tests/integration/test_update_account.py b/tests/integration/test_update_account.py
new file mode 100644
index 00000000..6c1dd608
--- /dev/null
+++ b/tests/integration/test_update_account.py
@@ -0,0 +1,149 @@
+from uuid import uuid4
+from tests.integration.conftest import TRUMPET
+from toot import api, cli
+from toot.entities import Account, from_dict
+from toot.utils import get_text
+
+
+def test_update_account_no_options(run):
+    result = run(cli.accounts.update_account)
+    assert result.exit_code == 1
+    assert result.stderr.strip() == "Error: Please specify at least one option to update the account"
+
+
+def test_update_account_display_name(run, app, user):
+    name = str(uuid4())[:10]
+
+    result = run(cli.accounts.update_account, "--display-name", name)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["display_name"] == name
+
+
+def test_update_account_json(run_json, app, user):
+    name = str(uuid4())[:10]
+    out = run_json(cli.accounts.update_account, "--display-name", name, "--json")
+    account = from_dict(Account, out)
+    assert account.acct == user.username
+    assert account.display_name == name
+
+
+def test_update_account_note(run, app, user):
+    note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack "
+           "of cigarettes, it's dark... and we're wearing sunglasses.")
+
+    result = run(cli.accounts.update_account, "--note", note)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert get_text(account["note"]) == note
+
+
+def test_update_account_language(run, app, user):
+    result = run(cli.accounts.update_account, "--language", "hr")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["source"]["language"] == "hr"
+
+
+def test_update_account_privacy(run, app, user):
+    result = run(cli.accounts.update_account, "--privacy", "private")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["source"]["privacy"] == "private"
+
+
+def test_update_account_avatar(run, app, user):
+    account = api.verify_credentials(app, user).json()
+    old_value = account["avatar"]
+
+    result = run(cli.accounts.update_account, "--avatar", TRUMPET)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["avatar"] != old_value
+
+
+def test_update_account_header(run, app, user):
+    account = api.verify_credentials(app, user).json()
+    old_value = account["header"]
+
+    result = run(cli.accounts.update_account, "--header", TRUMPET)
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["header"] != old_value
+
+
+def test_update_account_locked(run, app, user):
+    result = run(cli.accounts.update_account, "--locked")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["locked"] is True
+
+    result = run(cli.accounts.update_account, "--no-locked")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["locked"] is False
+
+
+def test_update_account_bot(run, app, user):
+    result = run(cli.accounts.update_account, "--bot")
+
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["bot"] is True
+
+    result = run(cli.accounts.update_account, "--no-bot")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["bot"] is False
+
+
+def test_update_account_discoverable(run, app, user):
+    result = run(cli.accounts.update_account, "--discoverable")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["discoverable"] is True
+
+    result = run(cli.accounts.update_account, "--no-discoverable")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["discoverable"] is False
+
+
+def test_update_account_sensitive(run, app, user):
+    result = run(cli.accounts.update_account, "--sensitive")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["source"]["sensitive"] is True
+
+    result = run(cli.accounts.update_account, "--no-sensitive")
+    assert result.exit_code == 0
+    assert result.stdout.strip() == "✓ Account updated"
+
+    account = api.verify_credentials(app, user).json()
+    assert account["source"]["sensitive"] is False
diff --git a/tests/test_api.py b/tests/test_api.py
deleted file mode 100644
index 3b5c5b14..00000000
--- a/tests/test_api.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import pytest
-
-from unittest import mock
-
-from toot import App, CLIENT_NAME, CLIENT_WEBSITE
-from toot.api import create_app, login, SCOPES, AuthenticationError
-from tests.utils import MockResponse
-
-
-@mock.patch('toot.http.anon_post')
-def test_create_app(mock_post):
-    mock_post.return_value = MockResponse({
-        'client_id': 'foo',
-        'client_secret': 'bar',
-    })
-
-    create_app('https://bigfish.software')
-
-    mock_post.assert_called_once_with('https://bigfish.software/api/v1/apps', json={
-        'website': CLIENT_WEBSITE,
-        'client_name': CLIENT_NAME,
-        'scopes': SCOPES,
-        'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob',
-    })
-
-
-@mock.patch('toot.http.anon_post')
-def test_login(mock_post):
-    app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
-
-    data = {
-        'grant_type': 'password',
-        'client_id': app.client_id,
-        'client_secret': app.client_secret,
-        'username': 'user',
-        'password': 'pass',
-        'scope': SCOPES,
-    }
-
-    mock_post.return_value = MockResponse({
-        'token_type': 'bearer',
-        'scope': 'read write follow',
-        'access_token': 'xxx',
-        'created_at': 1492523699
-    })
-
-    login(app, 'user', 'pass')
-
-    mock_post.assert_called_once_with(
-        'https://bigfish.software/oauth/token', data=data, allow_redirects=False)
-
-
-@mock.patch('toot.http.anon_post')
-def test_login_failed(mock_post):
-    app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
-
-    data = {
-        'grant_type': 'password',
-        'client_id': app.client_id,
-        'client_secret': app.client_secret,
-        'username': 'user',
-        'password': 'pass',
-        'scope': SCOPES,
-    }
-
-    mock_post.return_value = MockResponse(is_redirect=True)
-
-    with pytest.raises(AuthenticationError):
-        login(app, 'user', 'pass')
-
-    mock_post.assert_called_once_with(
-        'https://bigfish.software/oauth/token', data=data, allow_redirects=False)
diff --git a/tests/test_console.py b/tests/test_console.py
deleted file mode 100644
index 028e836b..00000000
--- a/tests/test_console.py
+++ /dev/null
@@ -1,449 +0,0 @@
-import io
-import pytest
-import re
-
-from collections import namedtuple
-from unittest import mock
-
-from toot import console, User, App, http
-from toot.exceptions import ConsoleError
-
-from tests.utils import MockResponse
-
-app = App('habunek.com', 'https://habunek.com', 'foo', 'bar')
-user = User('habunek.com', 'ivan@habunek.com', 'xxx')
-
-MockUuid = namedtuple("MockUuid", ["hex"])
-
-
-def uncolorize(text):
-    """Remove ANSI color sequences from a string"""
-    return re.sub(r'\x1b[^m]*m', '', text)
-
-
-def test_print_usage(capsys):
-    console.print_usage()
-    out, err = capsys.readouterr()
-    assert "toot - a Mastodon CLI client" in out
-
-
-@mock.patch('uuid.uuid4')
-@mock.patch('toot.http.post')
-def test_post_defaults(mock_post, mock_uuid, capsys):
-    mock_uuid.return_value = MockUuid("rock-on")
-    mock_post.return_value = MockResponse({
-        'url': 'https://habunek.com/@ihabunek/1234567890'
-    })
-
-    console.run_command(app, user, 'post', ['Hello world'])
-
-    mock_post.assert_called_once_with(app, user, '/api/v1/statuses', json={
-        'status': 'Hello world',
-        'visibility': 'public',
-        'media_ids': [],
-        'sensitive': False,
-    }, headers={"Idempotency-Key": "rock-on"})
-
-    out, err = capsys.readouterr()
-    assert 'Toot posted' in out
-    assert 'https://habunek.com/@ihabunek/1234567890' in out
-    assert not err
-
-
-@mock.patch('uuid.uuid4')
-@mock.patch('toot.http.post')
-def test_post_with_options(mock_post, mock_uuid, capsys):
-    mock_uuid.return_value = MockUuid("up-the-irons")
-    args = [
-        'Hello world',
-        '--visibility', 'unlisted',
-        '--sensitive',
-        '--spoiler-text', 'Spoiler!',
-        '--reply-to', '123a',
-        '--language', 'hr',
-    ]
-
-    mock_post.return_value = MockResponse({
-        'url': 'https://habunek.com/@ihabunek/1234567890'
-    })
-
-    console.run_command(app, user, 'post', args)
-
-    mock_post.assert_called_once_with(app, user, '/api/v1/statuses', json={
-        'status': 'Hello world',
-        'media_ids': [],
-        'visibility': 'unlisted',
-        'sensitive': True,
-        'spoiler_text': "Spoiler!",
-        'in_reply_to_id': '123a',
-        'language': 'hr',
-    }, headers={"Idempotency-Key": "up-the-irons"})
-
-    out, err = capsys.readouterr()
-    assert 'Toot posted' in out
-    assert 'https://habunek.com/@ihabunek/1234567890' in out
-    assert not err
-
-
-def test_post_invalid_visibility(capsys):
-    args = ['Hello world', '--visibility', 'foo']
-
-    with pytest.raises(SystemExit):
-        console.run_command(app, user, 'post', args)
-
-    out, err = capsys.readouterr()
-    assert "invalid visibility value: 'foo'" in err
-
-
-def test_post_invalid_media(capsys):
-    args = ['Hello world', '--media', 'does_not_exist.jpg']
-
-    with pytest.raises(SystemExit):
-        console.run_command(app, user, 'post', args)
-
-    out, err = capsys.readouterr()
-    assert "can't open 'does_not_exist.jpg'" in err
-
-
-@mock.patch('toot.http.delete')
-def test_delete(mock_delete, capsys):
-    console.run_command(app, user, 'delete', ['12321'])
-
-    mock_delete.assert_called_once_with(app, user, '/api/v1/statuses/12321')
-
-    out, err = capsys.readouterr()
-    assert 'Status deleted' in out
-    assert not err
-
-
-@mock.patch('toot.http.get')
-def test_timeline(mock_get, monkeypatch, capsys):
-    mock_get.return_value = MockResponse([{
-        'id': '111111111111111111',
-        'account': {
-            'display_name': 'Frank Zappa 🎸',
-            'last_status_at': '2017-04-12T15:53:18.174Z',
-            'acct': 'fz'
-        },
-        'created_at': '2017-04-12T15:53:18.174Z',
-        'content': "<p>The computer can&apos;t tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>",
-        'reblog': None,
-        'in_reply_to_id': None,
-        'media_attachments': [],
-    }])
-
-    console.run_command(app, user, 'timeline', ['--once'])
-
-    mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home', {'limit': 10})
-
-    out, err = capsys.readouterr()
-    lines = out.split("\n")
-
-    assert "Frank Zappa 🎸" in lines[1]
-    assert "@fz" in lines[1]
-    assert "2017-04-12 15:53 UTC" in lines[1]
-
-    assert (
-        "The computer can't tell you the emotional story. It can give you the "
-        "exact mathematical design, but\nwhat's missing is the eyebrows." in out)
-
-    assert "111111111111111111" in lines[-3]
-
-    assert err == ""
-
-
-@mock.patch('toot.http.get')
-def test_timeline_with_re(mock_get, monkeypatch, capsys):
-    mock_get.return_value = MockResponse([{
-        'id': '111111111111111111',
-        'created_at': '2017-04-12T15:53:18.174Z',
-        'account': {
-            'display_name': 'Frank Zappa',
-            'acct': 'fz'
-        },
-        'reblog': {
-            'created_at': '2017-04-12T15:53:18.174Z',
-            'account': {
-                'display_name': 'Johnny Cash',
-                'last_status_at': '2011-04-12',
-                'acct': 'jc'
-            },
-            'content': "<p>The computer can&apos;t tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>",
-            'media_attachments': [],
-        },
-        'in_reply_to_id': '111111111111111110',
-        'media_attachments': [],
-    }])
-
-    console.run_command(app, user, 'timeline', ['--once'])
-
-    mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home', {'limit': 10})
-
-    out, err = capsys.readouterr()
-    lines = uncolorize(out).split("\n")
-
-    assert "Johnny Cash" in lines[1]
-    assert "@jc" in lines[1]
-    assert "2017-04-12 15:53 UTC" in lines[1]
-
-    assert (
-        "The computer can't tell you the emotional story. It can give you the "
-        "exact mathematical design, but\nwhat's missing is the eyebrows." in out)
-
-    assert "111111111111111111" in lines[-3]
-    assert "↻ @fz boosted" in lines[-3]
-
-    assert err == ""
-
-
-@mock.patch('toot.http.post')
-def test_upload(mock_post, capsys):
-    mock_post.return_value = MockResponse({
-        'id': 123,
-        'preview_url': 'https://bigfish.software/789/012',
-        'url': 'https://bigfish.software/345/678',
-        'type': 'image',
-    })
-
-    console.run_command(app, user, 'upload', [__file__])
-
-    assert mock_post.call_count == 1
-
-    args, kwargs = http.post.call_args
-    assert args == (app, user, '/api/v2/media')
-    assert isinstance(kwargs['files']['file'], io.BufferedReader)
-
-    out, err = capsys.readouterr()
-    assert "Uploading media" in out
-    assert __file__ in out
-
-
-@mock.patch('toot.http.get')
-def test_whoami(mock_get, capsys):
-    mock_get.return_value = MockResponse({
-        'acct': 'ihabunek',
-        'avatar': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
-        'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
-        'created_at': '2017-04-04T13:23:09.777Z',
-        'display_name': 'Ivan Habunek',
-        'followers_count': 5,
-        'following_count': 9,
-        'header': '/headers/original/missing.png',
-        'header_static': '/headers/original/missing.png',
-        'id': 46103,
-        'locked': False,
-        'note': 'A developer.',
-        'statuses_count': 19,
-        'url': 'https://mastodon.social/@ihabunek',
-        'username': 'ihabunek',
-        'fields': []
-    })
-
-    console.run_command(app, user, 'whoami', [])
-
-    mock_get.assert_called_once_with(app, user, '/api/v1/accounts/verify_credentials')
-
-    out, err = capsys.readouterr()
-    out = uncolorize(out)
-
-    assert "@ihabunek Ivan Habunek" in out
-    assert "A developer." in out
-    assert "https://mastodon.social/@ihabunek" in out
-    assert "ID: 46103" in out
-    assert "Since: 2017-04-04" in out
-    assert "Followers: 5" in out
-    assert "Following: 9" in out
-    assert "Statuses: 19" in out
-
-
-@mock.patch('toot.http.get')
-def test_notifications(mock_get, capsys):
-    mock_get.return_value = MockResponse([{
-        'id': '1',
-        'type': 'follow',
-        'created_at': '2019-02-16T07:01:20.714Z',
-        'account': {
-            'display_name': 'Frank Zappa',
-            'acct': 'frank@zappa.social',
-        },
-    }, {
-        'id': '2',
-        'type': 'mention',
-        'created_at': '2017-01-12T12:12:12.0Z',
-        'account': {
-            'display_name': 'Dweezil Zappa',
-            'acct': 'dweezil@zappa.social',
-        },
-        'status': {
-            'id': '111111111111111111',
-            'account': {
-                'display_name': 'Dweezil Zappa',
-                'acct': 'dweezil@zappa.social',
-            },
-            'created_at': '2017-04-12T15:53:18.174Z',
-            'content': "<p>We still have fans in 2017 @fan123</p>",
-            'reblog': None,
-            'in_reply_to_id': None,
-            'media_attachments': [],
-        },
-    }, {
-        'id': '3',
-        'type': 'reblog',
-        'created_at': '1983-11-03T03:03:03.333Z',
-        'account': {
-            'display_name': 'Terry Bozzio',
-            'acct': 'terry@bozzio.social',
-        },
-        'status': {
-            'id': '1234',
-            'account': {
-                'display_name': 'Zappa Fan',
-                'acct': 'fan123@zappa-fans.social'
-            },
-            'created_at': '1983-11-04T15:53:18.174Z',
-            'content': "<p>The Black Page, a masterpiece</p>",
-            'reblog': None,
-            'in_reply_to_id': None,
-            'media_attachments': [],
-        },
-    }, {
-        'id': '4',
-        'type': 'favourite',
-        'created_at': '1983-12-13T01:02:03.444Z',
-        'account': {
-            'display_name': 'Zappa Old Fan',
-            'acct': 'fan9@zappa-fans.social',
-        },
-        'status': {
-            'id': '1234',
-            'account': {
-                'display_name': 'Zappa Fan',
-                'acct': 'fan123@zappa-fans.social'
-            },
-            'created_at': '1983-11-04T15:53:18.174Z',
-            'content': "<p>The Black Page, a masterpiece</p>",
-            'reblog': None,
-            'in_reply_to_id': None,
-            'media_attachments': [],
-        },
-    }])
-
-    console.run_command(app, user, 'notifications', [])
-
-    mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20})
-
-    out, err = capsys.readouterr()
-    out = uncolorize(out)
-
-    assert not err
-    assert out == "\n".join([
-        "────────────────────────────────────────────────────────────────────────────────────────────────────",
-        "Frank Zappa @frank@zappa.social now follows you",
-        "────────────────────────────────────────────────────────────────────────────────────────────────────",
-        "Dweezil Zappa @dweezil@zappa.social mentioned you in",
-        "Dweezil Zappa @dweezil@zappa.social                                             2017-04-12 15:53 UTC",
-        "",
-        "We still have fans in 2017 @fan123",
-        "",
-        "ID 111111111111111111   ",
-        "────────────────────────────────────────────────────────────────────────────────────────────────────",
-        "Terry Bozzio @terry@bozzio.social reblogged your status",
-        "Zappa Fan @fan123@zappa-fans.social                                             1983-11-04 15:53 UTC",
-        "",
-        "The Black Page, a masterpiece",
-        "",
-        "ID 1234   ",
-        "────────────────────────────────────────────────────────────────────────────────────────────────────",
-        "Zappa Old Fan @fan9@zappa-fans.social favourited your status",
-        "Zappa Fan @fan123@zappa-fans.social                                             1983-11-04 15:53 UTC",
-        "",
-        "The Black Page, a masterpiece",
-        "",
-        "ID 1234   ",
-        "────────────────────────────────────────────────────────────────────────────────────────────────────",
-        "",
-    ])
-
-
-@mock.patch('toot.http.get')
-def test_notifications_empty(mock_get, capsys):
-    mock_get.return_value = MockResponse([])
-
-    console.run_command(app, user, 'notifications', [])
-
-    mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20})
-
-    out, err = capsys.readouterr()
-    out = uncolorize(out)
-
-    assert not err
-    assert out == "No notification\n"
-
-
-@mock.patch('toot.http.post')
-def test_notifications_clear(mock_post, capsys):
-    console.run_command(app, user, 'notifications', ['--clear'])
-    out, err = capsys.readouterr()
-    out = uncolorize(out)
-
-    mock_post.assert_called_once_with(app, user, '/api/v1/notifications/clear')
-    assert not err
-    assert out == 'Cleared notifications\n'
-
-
-def u(user_id, access_token="abc"):
-    username, instance = user_id.split("@")
-    return {
-        "instance": instance,
-        "username": username,
-        "access_token": access_token,
-    }
-
-
-@mock.patch('toot.config.save_config')
-@mock.patch('toot.config.load_config')
-def test_logout(mock_load, mock_save, capsys):
-    mock_load.return_value = {
-        "users": {
-            "king@gizzard.social": u("king@gizzard.social"),
-            "lizard@wizard.social": u("lizard@wizard.social"),
-        },
-        "active_user": "king@gizzard.social",
-    }
-
-    console.run_command(app, user, "logout", ["king@gizzard.social"])
-
-    mock_save.assert_called_once_with({
-        'users': {
-            'lizard@wizard.social': u("lizard@wizard.social")
-        },
-        'active_user': None
-    })
-
-    out, err = capsys.readouterr()
-    assert "✓ User king@gizzard.social logged out" in out
-
-
-@mock.patch('toot.config.save_config')
-@mock.patch('toot.config.load_config')
-def test_activate(mock_load, mock_save, capsys):
-    mock_load.return_value = {
-        "users": {
-            "king@gizzard.social": u("king@gizzard.social"),
-            "lizard@wizard.social": u("lizard@wizard.social"),
-        },
-        "active_user": "king@gizzard.social",
-    }
-
-    console.run_command(app, user, "activate", ["lizard@wizard.social"])
-
-    mock_save.assert_called_once_with({
-        'users': {
-            "king@gizzard.social": u("king@gizzard.social"),
-            'lizard@wizard.social': u("lizard@wizard.social")
-        },
-        'active_user': "lizard@wizard.social"
-    })
-
-    out, err = capsys.readouterr()
-    assert "✓ User lizard@wizard.social active" in out
diff --git a/tests/test_output.py b/tests/test_output.py
deleted file mode 100644
index cc31e5cc..00000000
--- a/tests/test_output.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from toot.output import colorize, strip_tags, STYLES
-
-reset = STYLES["reset"]
-red = STYLES["red"]
-green = STYLES["green"]
-bold = STYLES["bold"]
-
-
-def test_colorize():
-    assert colorize("foo") == "foo"
-    assert colorize("<red>foo</red>") == f"{red}foo{reset}{reset}"
-    assert colorize("foo <red>bar</red> baz") == f"foo {red}bar{reset} baz{reset}"
-    assert colorize("foo <red bold>bar</red bold> baz") == f"foo {red}{bold}bar{reset} baz{reset}"
-    assert colorize("foo <red bold>bar</red> baz") == f"foo {red}{bold}bar{reset}{bold} baz{reset}"
-    assert colorize("foo <red bold>bar</> baz") == f"foo {red}{bold}bar{reset} baz{reset}"
-    assert colorize("<red>foo<bold>bar</bold>baz</red>") == f"{red}foo{bold}bar{reset}{red}baz{reset}{reset}"
-
-
-def test_strip_tags():
-    assert strip_tags("foo") == "foo"
-    assert strip_tags("<red>foo</red>") == "foo"
-    assert strip_tags("foo <red>bar</red> baz") == "foo bar baz"
-    assert strip_tags("foo <red bold>bar</red bold> baz") == "foo bar baz"
-    assert strip_tags("foo <red bold>bar</red> baz") == "foo bar baz"
-    assert strip_tags("foo <red bold>bar</> baz") == "foo bar baz"
-    assert strip_tags("<red>foo<bold>bar</bold>baz</red>") == "foobarbaz"
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 9dbb579a..906a3511 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,7 @@
-from argparse import ArgumentTypeError
+import click
 import pytest
 
-from toot.console import duration
+from toot.cli.validators import validate_duration
 from toot.wcstring import wc_wrap, trunc, pad, fit_text
 from toot.utils import urlencode_url
 
@@ -163,6 +163,9 @@ def test_wc_wrap_indented():
 
 
 def test_duration():
+    def duration(value):
+        return validate_duration(None, None, value)
+
     # Long hand
     assert duration("1 second") == 1
     assert duration("1 seconds") == 1
@@ -190,17 +193,17 @@ def test_duration():
     assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
     assert duration("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
 
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(click.BadParameter):
         duration("")
 
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(click.BadParameter):
         duration("100")
 
     # Wrong order
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(click.BadParameter):
         duration("1m1d")
 
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(click.BadParameter):
         duration("banana")