diff --git a/algobowl/templates/cli_setup.xhtml b/algobowl/templates/cli_setup.xhtml
index 054dd43..34d564a 100644
--- a/algobowl/templates/cli_setup.xhtml
+++ b/algobowl/templates/cli_setup.xhtml
@@ -7,40 +7,70 @@
       <h1>Command Line Interface (Beta)</h1>
 
       <p>
-        AlgoBOWL currently has an <strong>experimental</strong> command
-        line you can use to interface with it.  It is not currently to
-        feature-parity with the web interface.
+        AlgoBOWL has a command-line interface for power-users to interact with
+        the site from the command-line or from scripts.  The CLI is currently in
+        beta, and commands may be subject to change in the future.  Not all
+        functionality that's currently available on the site is also implemented
+        in the CLI.
       </p>
 
-      <h2>Prerequisites</h2>
+      <h2>Installation</h2>
 
-      <ul>
-        <li>Python 3.7+</li>
-        <li><tt>pip</tt> Package Manager</li>
-      </ul>
-
-      <h2>Installing the CLI</h2>
+      <p>
+      Download the
+      <a href="https://raw.githubusercontent.com/jackrosenthal/algobowl/main/cli_launcher.py">launcher script</a>
+      and mark it executable.  For example:
+      </p>
 
       <p>
-        Run <tt>pip install --user algobowl</tt>.
-        Ensure <tt>~/.local/bin</tt> is in your <tt>PATH</tt>.
+        <tt>curl https://raw.githubusercontent.com/jackrosenthal/algobowl/main/cli_launcher.py -o algobowl &amp;&amp; chmod +x algobowl</tt>
       </p>
 
-      <h2>Authentication</h2>
+      <p>
+        You can either put it in a location referenced by your <tt>PATH</tt>
+        environment variable and run it as <tt>algobowl</tt>, or it's designed
+        so you can check it in to your group's Git repo as well (and run it as
+        <tt>./algobowl</tt>).
+      </p>
 
       <p>
-        First, set the default server for your commands by running
-        <tt>algobowl config set-default-server ${tg.request.application_url}</tt>.
+        The launcher is designed to depend on nothing but Python 3.8+, so you
+        don't need to worry about installing any dependencies.
       </p>
 
+      <h3>Authentication</h3>
+
       <p>
-        Then, run <tt>algobowl auth login</tt> and follow the instructions.
+        Run <tt>algobowl auth login</tt> and follow the instructions.
       </p>
 
       <h2>Usage</h2>
 
       <p>
-        Run <tt>algobowl --help</tt>.
+        Run <tt>algobowl --help</tt> for help.  Here are some commands you may
+        be interested in:
+      </p>
+
+      <h3>Input Upload</h3>
+
+      <ul>
+        <li><tt>algobowl group input upload FILENAME</tt>: Upload your group's input.</li>
+        <li><tt>algobowl group input download OUTPUT_FILE</tt>: Download your group's input.</li>
+        <li><tt>algobowl group set-team-name TEAM_NAME</tt>: Set your team name.</li>
+      </ul>
+
+      <h3>Output Upload</h3>
+
+      <ul>
+        <li><tt>algobowl group output --to-group-id GROUP_ID upload FILENAME</tt>: Upload an output.</li>
+        <li><tt>algobowl group output --to-group-id GROUP_ID download OUTPUT_FILE</tt>: Download one of your submitted outputs.</li>
+        <li><tt>algobowl group output list</tt>: List output files you'll need to provide.</li>
+      </ul>
+
+      <p>
+        Note: if you use filenames containing the group ID, (e.g.,
+        <tt>output_group123.txt</tt>), the CLI can infer the group ID from the
+        filename, and passing <tt>--to-group-id</tt> is not required.
       </p>
 
       <h2>Logged-in Clients</h2>
diff --git a/algobowl/templates/master.xhtml b/algobowl/templates/master.xhtml
index f5d871a..efb027e 100644
--- a/algobowl/templates/master.xhtml
+++ b/algobowl/templates/master.xhtml
@@ -84,6 +84,10 @@
                 <i class="fas fa-users fa-fw"></i>
                 My Group
               </a>
+              <a class="dropdown-item" href="${tg.url('/pref/cli')}">
+                <i class="fas fa-terminal fa-fw"></i>
+                Command Line Interface
+              </a>
               <a class="dropdown-item" py:if="request.identity['user'].admin"
                  href="${tg.url('/admin')}">
                 <i class="fas fa-user-secret fa-fw"></i>
diff --git a/cli_launcher.py b/cli_launcher.py
new file mode 100755
index 0000000..add7e3a
--- /dev/null
+++ b/cli_launcher.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+
+"""Launcher for AlgoBOWL CLI.
+
+This is a small little launcher script for the AlgoBOWL CLI.  It launches the
+CLI creating a Python virtual environment and installing it from GitHub.  The
+launcher will update the CLI on startup every 24 hours.  By design, this has no
+dependencies other than Python 3.8+ and is a single file.  You should be able to
+"chmod +x" this script and put it in your PATH, or put it in your team's Git
+repo for everyone on your team to use.
+
+This launcher can be downloaded from:
+https://raw.githubusercontent.com/jackrosenthal/algobowl/main/cli_launcher.py
+
+All command line arguments are passed as-is to the real AlgoBOWL CLI.  It can
+minimally be configured via environment variables:
+
+- ALGOBOWL_VENV: Path to the virtual environment to use (by default, create one
+  in the XDG cache directory).
+- ALGOBOWL_FORCE_UPDATE: Set to 1 to force the launcher to re-build the virtual
+  environment.
+- ALGOBOWL_NO_UPDATE: Set to 1 to force the launcher to not update the virtual
+  environment.
+"""
+
+import datetime
+import os
+import subprocess
+import sys
+import venv
+from pathlib import Path
+
+assert sys.version_info >= (3, 8), "AlgoBOWL CLI requires Python 3.8+"
+
+
+def quiet_run(argv) -> None:
+    """Run a command, staying quiet unless there's an error."""
+    try:
+        subprocess.run(
+            argv, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
+        )
+    except subprocess.CalledProcessError as e:
+        print(f"Command failed ({argv})!", file=sys.stderr)
+        sys.stderr.write(subprocess.stdout)
+        raise
+
+
+def get_cache_dir() -> Path:
+    """Get the XDG-specified cache directory."""
+    xdg_cache_home = os.environ.get("XDG_CACHE_HOME")
+    if xdg_cache_home:
+        return Path(xdg_cache_home)
+    return Path.home() / ".cache"
+
+
+def get_venv_dir() -> Path:
+    """Get the path to the virtual environment to use."""
+    venv_dir = os.environ.get("ALGOBOWL_VENV")
+    if venv_dir:
+        return Path(venv_dir)
+
+    return (
+        get_cache_dir()
+        / "algobowl"
+        / f"venv-{sys.version_info.major}.{sys.version_info.minor}"
+    )
+
+
+def venv_cmd(executable: str) -> Path:
+    """Get the path to a command in the virtual environment."""
+    return get_venv_dir() / "bin" / executable
+
+
+def build_venv() -> None:
+    """Build the virtual environment."""
+    venv.EnvBuilder(
+        system_site_packages=False,
+        clear=bool(os.environ.get("ALGOBOWL_FORCE_UPDATE")),
+        symlinks=True,
+        with_pip=True,
+    ).create(get_venv_dir())
+    quiet_run([venv_cmd("pip"), "install", "--upgrade", "pip"])
+    url = os.environ.get(
+        "ALGOBOWL_URL",
+        "https://github.com/jackrosenthal/algobowl/archive/refs/heads/main.zip",
+    )
+    quiet_run([venv_cmd("pip"), "install", "--upgrade", url])
+
+
+def update_venv() -> None:
+    """Build the virtual environment if necessary."""
+    if os.environ.get("ALGOBOWL_NO_UPDATE"):
+        return
+    update_file = get_venv_dir() / "UPDATE"
+    force_update = os.environ.get("ALGOBOWL_FORCE_UPDATE")
+    now = datetime.datetime.now()
+    if not force_update and update_file.exists():
+        last_update = datetime.datetime.fromisoformat(
+            update_file.read_text(encoding="ascii")
+        )
+        required_update = last_update + datetime.timedelta(hours=24)
+        if required_update > now:
+            return
+    build_venv()
+    update_file.write_text(now.isoformat(), encoding="ascii")
+
+
+def main():
+    """The main function."""
+    update_venv()
+    algobowl = venv_cmd("algobowl")
+    os.execv(venv_cmd("algobowl"), sys.argv)
+
+
+if __name__ == "__main__":
+    main()