Recently Written · git

deploy-handoff

Claude Code plugin: the agent drives the browser to the last deploy, billing, or pull request step. You make the final click.

git clone https://github.com/equwal/deploy-handoff

Log | Files | Refs


commit 43d1af77a2b9ae0b9330a1efe90ba4a1abdc2f65
equwal <13551856+equwal@users.noreply.github.com>
2026-09-21 13:27:16 -0700

Add deploy-handoff plugin

deploy-handoff shows a dialog that tells the user the last step of a
deploy, billing, or sign-in task. The agent drives the browser to that
step first. The user makes the final click, and the agent gets the
answer as JSON.

- handoff.py open: opens an https page on an allowed host in the
  default browser, or shows only the dialog with --no-open.
- handoff.py pr: opens the GitHub pull request form with the title and
  the description filled in. It refuses a branch that is not pushed.
- The script refuses a title or a step that is not short Simplified
  Technical English.

 .claude-plugin/marketplace.json       |  22 ++
 .claude-plugin/plugin.json            |  13 +
 .gitattributes                        |   1 +
 .gitignore                            |   5 +
 LICENSE                               |  21 ++
 README.md                             |  78 ++++++
 pyproject.toml                        |  18 ++
 skills/deploy-handoff/SKILL.md        | 119 +++++++++
 skills/deploy-handoff/config.toml     |  24 ++
 skills/deploy-handoff/handoff.py      | 369 +++++++++++++++++++++++++++
 skills/deploy-handoff/test_handoff.py | 460 ++++++++++++++++++++++++++++++++++
 11 files changed, 1130 insertions(+)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..0f10a01
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,22 @@
+{
+  "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
+  "name": "deploy-handoff",
+  "description": "deploy-handoff: the human makes the final deploy, billing, and pull request click",
+  "owner": {
+    "name": "equwal",
+    "url": "https://github.com/equwal"
+  },
+  "plugins": [
+    {
+      "name": "deploy-handoff",
+      "description": "Opens deploy, billing, sign-in, and pull request pages in your browser, and waits while you make the final click. Claude never makes it for you.",
+      "version": "0.1.0",
+      "author": {
+        "name": "equwal"
+      },
+      "source": "./",
+      "category": "deployment",
+      "homepage": "https://github.com/equwal/deploy-handoff"
+    }
+  ]
+}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
new file mode 100644
index 0000000..9625346
--- /dev/null
+++ b/.claude-plugin/plugin.json
@@ -0,0 +1,13 @@
+{
+  "name": "deploy-handoff",
+  "version": "0.1.0",
+  "description": "Opens deploy, billing, sign-in, and pull request pages in your browser, and waits while you make the final click. Claude never makes it for you.",
+  "author": {
+    "name": "equwal",
+    "url": "https://github.com/equwal"
+  },
+  "homepage": "https://github.com/equwal/deploy-handoff",
+  "repository": "https://github.com/equwal/deploy-handoff",
+  "license": "MIT",
+  "keywords": ["deploy", "billing", "human-in-the-loop", "pull-request", "browser", "stripe", "google-play", "f-droid"]
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2d38043
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.venv/
+__pycache__/
+.hypothesis/
+.mypy_cache/
+.ruff_cache/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..160e1db
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 equwal
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..34c4281
--- /dev/null
+++ b/README.md
@@ -0,0 +1,78 @@
+# deploy-handoff
+
+A Claude Code plugin for the steps that an AI agent must not do alone.
+
+An agent can build, test, and upload a release. Some steps must come from a person: publish the release, turn on live payments, accept an OAuth consent, or create a pull request. The agent drives the browser as far as it can. Then deploy-handoff shows a small dialog on top of the browser. The dialog tells you what to do, in short steps in [Simplified Technical English](https://asd-ste100.org). You do the final click. Then you click **Done** or **Not done**, and the agent continues.
+
+deploy-handoff never clicks for you. It never asks for a password or a key.
+
+## Install
+
+In Claude Code:
+
+```
+/plugin marketplace add equwal/deploy-handoff
+/plugin install deploy-handoff@deploy-handoff
+```
+
+Requirements:
+
+- Python 3.11 or later, with Tk. The python.org installers for Windows and macOS include Tk. On Debian and Ubuntu, install `python3-tk`.
+- `git` for the `pr` command. `gh` is optional. If `gh` is available, the `pr` command gives the URL of the new pull request.
+
+## How it works
+
+The plugin adds the skill `deploy-handoff`. Claude uses the skill when a task reaches a human step, and when it opens a GitHub pull request. The skill runs `skills/deploy-handoff/handoff.py`. Other tools can run the script in the same way.
+
+Open a page and show the steps:
+
+```bash
+python3 skills/deploy-handoff/handoff.py open \
+  --url https://play.google.com/console \
+  --title "Send version 1.4.0 for review" \
+  --step 'Open "Publishing overview".' \
+  --step 'Click "Send changes for review".'
+```
+
+If the agent already drove a browser to the page, it adds `--no-open`. Then the script shows only the dialog. The script refuses a title or a step with more than 20 words or with a semicolon.
+
+Open the pull request form for the current branch:
+
+```bash
+python3 skills/deploy-handoff/handoff.py pr --title "Add CSV export" --body-file body.md
+```
+
+The script prints one JSON object, for example `{"status": "done", "note": ""}`. [SKILL.md](skills/deploy-handoff/SKILL.md) lists all statuses and exit codes.
+
+## Browser
+
+The script opens pages in a new tab of your default browser. To use a different browser, set the `BROWSER` environment variable. The Python `webbrowser` module reads it.
+
+## Allowed hosts
+
+The script opens only `https` pages on allowed hosts. This stops an agent that follows bad instructions from sending you to a fake sign-in page. The dialog also shows the host of the page.
+
+[config.toml](skills/deploy-handoff/config.toml) has the default list: GitHub, GitLab, Stripe, Google Play, F-Droid, App Store Connect, and some cloud and hosting consoles. An entry also allows its subdomains.
+
+To add hosts, make the file `~/.config/deploy-handoff/config.toml`. If `XDG_CONFIG_HOME` is set, the file is `$XDG_CONFIG_HOME/deploy-handoff/config.toml`.
+
+```toml
+allowed_hosts = ["dashboard.example.com"]
+```
+
+## Development
+
+```bash
+python3 -m venv .venv
+.venv/bin/python -m pip install --group dev
+.venv/bin/python -m ruff format --check
+.venv/bin/python -m ruff check
+.venv/bin/python -m mypy skills/deploy-handoff
+.venv/bin/python -m unittest discover -s skills/deploy-handoff
+```
+
+On Windows, use `.venv\Scripts\python`. `pip install --group` needs pip 25.1 or later. The dialog tests show small windows for a short time.
+
+## License
+
+MIT
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..97cb5da
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,18 @@
+# handoff.py uses only the Python standard library.
+# The group below holds the tools that check it. README.md tells how to run them.
+
+[dependency-groups]
+dev = ["hypothesis", "mypy", "ruff"]
+
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
+
+[tool.ruff.lint.isort]
+known-first-party = ["handoff"]
+
+[tool.mypy]
+strict = true
diff --git a/skills/deploy-handoff/SKILL.md b/skills/deploy-handoff/SKILL.md
new file mode 100644
index 0000000..9d6e0da
--- /dev/null
+++ b/skills/deploy-handoff/SKILL.md
@@ -0,0 +1,119 @@
+---
+name: deploy-handoff
+description: >-
+  Gives the last step of a deploy, billing, or sign-in task to the user. Drive
+  the browser as far as you can, then use this skill. It shows the user a
+  dialog that tells what to do: the final publish, pay, or submit click, a
+  sign-in, an OAuth consent, or the creation of an API key. Examples: Stripe
+  live mode, Google Play Console releases, F-Droid merge requests, hosting
+  consoles. Also use it to open every GitHub pull request, instead of gh pr
+  create or another pull request tool.
+---
+
+# deploy-handoff
+
+`handoff.py` is in the base directory of this skill. In the commands below, replace `SKILL_DIR` with that directory. Run the script with Python 3.11 or later: `py -3` on Windows, `python3` on other systems.
+
+Run the commands in a POSIX shell. On Windows, use Git Bash. Windows PowerShell 5.1 removes the double quotation marks inside the arguments.
+
+The script shows a small dialog on top of the browser. The dialog tells the user what to do. The user does the last step and clicks **Done** or **Not done**. The script never clicks for the user.
+
+## 1. Drive the browser to the last step
+
+The user must do only the last step. Do all the other work first.
+
+1. Do the work that needs no browser. For example, push the branch or upload the build with an API.
+2. If you have browser tools, go to the page of the last step. Examples of browser tools are Claude in Chrome and a Playwright MCP server. Use the browser in which the user is signed in, if you can.
+3. Open the correct app, release, or settings page. Fill in the fields that do not contain secrets.
+4. Stop before the last step. Then run `handoff.py open --no-open` with the URL of the current page.
+
+If you have no browser tools, give `handoff.py` the URL of the deepest page that you know. Do not give the home page of the console.
+
+Never do these steps yourself. Give them to the user:
+
+- Type a password, a one-time code, an API key, or a card number.
+- Solve a CAPTCHA.
+- Accept terms or an OAuth consent screen.
+- Click the final button that publishes, pays, submits, merges, or deletes.
+
+## 2. Write the steps in Simplified Technical English
+
+The user reads the title and the steps in the dialog. Write them in ASD-STE100 Simplified Technical English:
+
+- Start each step with a verb in the imperative, for example Click, Open, Select, or Make sure.
+- Give one action in each step. Use 20 words or fewer. Do not use semicolons.
+- Write the exact name of each button or menu item in quotation marks.
+- Put a condition before its action: 'If the page asks for a code, type the code from your phone.'
+- Make the title the result of the steps, for example 'Send version 1.4.0 for review'.
+
+The script refuses a title or a step that has more than 20 words or a semicolon.
+
+- Good: `Click "Send changes for review".`
+- Bad: `Review everything, then submit it and tell me when it is done; check the release notes too.`
+
+## 3. Run the handoff
+
+```bash
+python3 SKILL_DIR/handoff.py open --no-open \
+  --url "https://play.google.com/console/u/0/developers/123/app/456/publishing" \
+  --title "Send version 1.4.0 for review" \
+  --step 'Make sure that the page shows version 1.4.0.' \
+  --step 'Click "Send changes for review".'
+```
+
+- Do not give `--no-open` if you did not open the page. Then the script opens the URL in a new tab of the default browser.
+- The URL must use `https` and printable ASCII. Percent-encode all other characters.
+- The host must be an allowed host. If the script refuses the host, ask the user to add it. Do not add it yourself.
+
+Useful start pages:
+
+| Service | Page |
+|---|---|
+| Stripe API keys | `https://dashboard.stripe.com/apikeys` (test mode: `https://dashboard.stripe.com/test/apikeys`) |
+| Google Play Console | `https://play.google.com/console` |
+| F-Droid merge requests | `https://gitlab.com/fdroid/fdroiddata/-/merge_requests` |
+| GitHub device sign-in | `https://github.com/login/device` |
+
+## Open a pull request
+
+Push the branch first. Then run this command in the repository:
+
+```bash
+python3 SKILL_DIR/handoff.py pr --title "Add CSV export" --body-file pr-body.md
+```
+
+The script opens the GitHub form with the title and the description filled in. The user only clicks "Create pull request".
+
+- The head is the current branch. The base is the default branch on GitHub. Use `--head` and `--base` to change them.
+- The script refuses the branch if the remote does not have the local commit.
+- The script gets OWNER/NAME from the URL of `origin`. Use `--remote` or `--repo OWNER/NAME` to change it.
+- Do not create a pull request with `gh pr create`, the GitHub API, or another tool.
+
+## 4. Wait for the answer
+
+The user can take many minutes. Run the command in the background (in Claude Code: `run_in_background: true`). Tell the user in one sentence that the dialog is open. The default time limit is 30 minutes. `--timeout MINUTES` changes it.
+
+The script prints one JSON object:
+
+| `status` | Exit code | Meaning |
+|---|---|---|
+| `done` | 0 | The user did the steps. |
+| `not_done` | 3 | The user did not do the steps. `note` can give the reason. |
+| `timeout` | 4 | The user did not answer in time. |
+| `error` | 1 | The script did not show the dialog. `error` gives the reason. |
+
+Exit code 2 means bad arguments. Then the script writes the usage to stderr.
+
+After `pr`, a `done` answer also has `pr_url`. It is the open pull request that `gh` found for the branch, or `null` if `gh` is not available or found none.
+
+## 5. After the answer
+
+- `done`: Check the result with the API or CLI of the service when you can, for example `gh pr view`. Then continue.
+- `not_done`: Read the note. Do not show the same steps again without a change. Ask the user what to do next.
+- `timeout`: Ask the user before you try again.
+- `error`: Fix the cause. If the host is not allowed, ask the user.
+
+## Safety
+
+- Never ask the user to type or paste a password, key, token, or card number into the chat or the note. Tell the user where to put a secret, for example in the secret store of the host.
+- Never click the final button yourself with a browser tool.
diff --git a/skills/deploy-handoff/config.toml b/skills/deploy-handoff/config.toml
new file mode 100644
index 0000000..032c603
--- /dev/null
+++ b/skills/deploy-handoff/config.toml
@@ -0,0 +1,24 @@
+# The hosts that handoff.py can open. An entry also allows its subdomains.
+# Do not edit this file to add hosts, because an update replaces it.
+# Put your hosts in allowed_hosts in ~/.config/deploy-handoff/config.toml.
+allowed_hosts = [
+  # Code and pull requests
+  "github.com",
+  "gitlab.com",
+  # Payments
+  "stripe.com",
+  # Android and iOS stores
+  "play.google.com",
+  "payments.google.com",
+  "f-droid.org",
+  "appstoreconnect.apple.com",
+  "developer.apple.com",
+  # Sign-in and cloud consoles
+  "accounts.google.com",
+  "console.cloud.google.com",
+  # Hosting
+  "vercel.com",
+  "app.netlify.com",
+  "dash.cloudflare.com",
+  "fly.io",
+]
diff --git a/skills/deploy-handoff/handoff.py b/skills/deploy-handoff/handoff.py
new file mode 100644
index 0000000..5f537d8
--- /dev/null
+++ b/skills/deploy-handoff/handoff.py
@@ -0,0 +1,369 @@
+"""Open a web page and wait while the user does a step that only a human may do.
+
+A tool calls this script when it reaches a step that an AI agent must not do:
+the final deploy, publish, or billing click, a sign-in, an OAuth consent, or
+the creation of a GitHub pull request. The script opens the page in the
+default browser and shows a small dialog with the steps. It never clicks for
+the user. It prints the answer of the user as one JSON object on stdout.
+
+Exit codes: 0 done, 1 error, 2 bad arguments, 3 not done, 4 timeout.
+"""
+
+import argparse
+import contextlib
+import ctypes
+import json
+import os
+import re
+import subprocess
+import sys
+import tkinter as tk
+import tomllib
+import webbrowser
+from collections.abc import Callable, Iterable
+from dataclasses import dataclass
+from pathlib import Path
+from tkinter import font as tkfont
+from tkinter import ttk
+from urllib.parse import quote, urlencode, urlsplit
+
+SHIPPED_CONFIG = Path(__file__).with_name("config.toml")
+USER_CONFIG = (
+    Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
+    / "deploy-handoff"
+    / "config.toml"
+)
+# GitHub refuses very long request lines. This limit keeps a margin.
+MAX_PR_URL_LENGTH = 8000
+EXIT_CODES = {"done": 0, "error": 1, "not_done": 3, "timeout": 4}
+# ASD-STE100 Simplified Technical English allows 20 words in one instruction.
+MAX_STEP_WORDS = 20
+PR_STEPS = (
+    "Make sure that the branches, the title, and the description are correct.",
+    'Click "Create pull request".',
+)
+HEADING = "Do these steps in your browser:"
+FINISH = (
+    'When you finish, click "Done".\n'
+    'If you cannot finish, write the reason in the note. Then click "Not done".'
+)
+WARNING = (
+    "Claude does not click for you. Before you sign in, make sure that the address bar "
+    "shows the site above. Do not type a password, key, or card number in this window."
+)
+
+# A plain DNS name in lowercase ASCII. The script refuses all other host forms,
+# so the browser and this script always read the same host from a URL.
+HOST_RE = re.compile(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+")
+# Printable ASCII without a space. Callers percent-encode all other characters.
+URL_CHARS_RE = re.compile(r"[!-~]+")
+REPO_RE = re.compile(r"[A-Za-z0-9-]+/[A-Za-z0-9._-]+")
+GITHUB_REMOTE_RE = re.compile(
+    r"(?:https://(?:[^@/]+@)?github\.com/|git@github\.com:|ssh://git@github\.com/)"
+    r"(?P<repo>[A-Za-z0-9-]+/[A-Za-z0-9._-]+?)(?:\.git)?/?"
+)
+
+
+class HandoffError(Exception):
+    """The script cannot start the handoff. The message tells the caller why."""
+
+
+@dataclass(frozen=True)
+class Request:
+    """A page to open, and the steps that the user must do on it."""
+
+    title: str
+    url: str
+    steps: tuple[str, ...]
+    # For a pull request: the repository as "owner/name", and the head branch.
+    pr: tuple[str, str] | None = None
+
+
+def match_domain(host: str, domains: Iterable[str]) -> str | None:
+    """Return the most specific domain that is host or a parent domain of host."""
+    matches = [domain for domain in domains if host == domain or host.endswith("." + domain)]
+    return max(matches, key=len, default=None)
+
+
+def check_url(url: str, domains: Iterable[str]) -> str:
+    """Return the domain that allows url. Raise HandoffError if the script must not open url."""
+    if "\\" in url or not URL_CHARS_RE.fullmatch(url):
+        raise HandoffError(
+            f"The URL must be printable ASCII with no spaces or backslashes: {url!r}"
+        )
+    try:
+        parts = urlsplit(url)
+    except ValueError as exc:
+        raise HandoffError(f"The URL is not valid: {url}") from exc
+    host = parts.hostname or ""
+    # The netloc must be the host only: no user name, no password, and no port.
+    if parts.scheme != "https" or parts.netloc.lower() != host or not HOST_RE.fullmatch(host):
+        raise HandoffError(f"The URL must use https and a plain host name: {url}")
+    domain = match_domain(host, domains)
+    if domain is None:
+        raise HandoffError(
+            f"{host} is not an allowed host. Ask the user to add it to allowed_hosts in "
+            f"{USER_CONFIG}. Do not add it yourself."
+        )
+    return domain
+
+
+def check_text(title: str, steps: Iterable[str]) -> None:
+    """Raise HandoffError if the title or a step is not short Simplified Technical English."""
+    named = [("The title", title), *((f"Step {n}", step) for n, step in enumerate(steps, 1))]
+    for name, text in named:
+        words = len(text.split())
+        if not 0 < words <= MAX_STEP_WORDS or ";" in text:
+            raise HandoffError(
+                f"{name} is not short Simplified Technical English ({words} words). "
+                f"Give one action in 1 to {MAX_STEP_WORDS} words, with no semicolons."
+            )
+
+
+def read_hosts(path: Path) -> list[str]:
+    """Return the allowed_hosts list of the TOML file at path."""
+    try:
+        data = tomllib.loads(path.read_text(encoding="utf-8"))
+    except (OSError, tomllib.TOMLDecodeError) as exc:
+        raise HandoffError(f"Cannot read {path}: {exc}") from exc
+    hosts = data.get("allowed_hosts", [])
+    if not isinstance(hosts, list) or not all(
+        isinstance(host, str) and HOST_RE.fullmatch(host) for host in hosts
+    ):
+        raise HandoffError(f"{path}: allowed_hosts must be a list of lowercase host names.")
+    return hosts
+
+
+def allowed_hosts() -> list[str]:
+    """Return the hosts in the shipped file, and in the user file if it exists."""
+    paths = [SHIPPED_CONFIG, USER_CONFIG] if USER_CONFIG.is_file() else [SHIPPED_CONFIG]
+    return [host for path in paths for host in read_hosts(path)]
+
+
+def open_page(url: str) -> None:
+    """Open url in a new tab of the default browser. BROWSER can name a different browser."""
+    if not webbrowser.open(url, new=2):
+        raise HandoffError("No browser opened the page. Set the BROWSER environment variable.")
+
+
+def github_repo(remote_url: str) -> str:
+    """Return "owner/name" for the URL of a github.com remote."""
+    match = GITHUB_REMOTE_RE.fullmatch(remote_url.strip())
+    if match is None:
+        raise HandoffError(f"{remote_url} is not a github.com remote. Give --repo OWNER/NAME.")
+    return match["repo"]
+
+
+def compare_url(repo: str, base: str | None, head: str, title: str, body: str) -> str:
+    """Return the GitHub page that shows a new pull request form with title and body filled in."""
+    # Without a base, GitHub compares head with the default branch.
+    refs = quote(head) if base is None else f"{quote(base)}...{quote(head)}"
+    query = urlencode({"expand": "1", "title": title, "body": body}, quote_via=quote)
+    url = f"https://github.com/{repo}/compare/{refs}?{query}"
+    if len(url) > MAX_PR_URL_LENGTH:
+        raise HandoffError(
+            f"The pull request URL has {len(url)} characters. The limit is {MAX_PR_URL_LENGTH}. "
+            "Make the body shorter."
+        )
+    return url
+
+
+def git(repo_dir: Path, *args: str) -> str:
+    """Run git in repo_dir and return its output. Raise HandoffError if git fails."""
+    command = ["git", "-C", str(repo_dir), *args]
+    try:
+        result = subprocess.run(
+            command, capture_output=True, encoding="utf-8", errors="replace", timeout=60
+        )
+    except (OSError, subprocess.TimeoutExpired) as exc:
+        raise HandoffError(f"{' '.join(command)} failed: {exc}") from exc
+    if result.returncode != 0:
+        raise HandoffError(f"{' '.join(command)} failed: {result.stderr.strip()}")
+    return result.stdout.strip()
+
+
+def current_branch(repo_dir: Path) -> str:
+    """Return the branch that HEAD is on."""
+    branch = git(repo_dir, "branch", "--show-current")
+    if not branch:
+        raise HandoffError("HEAD is not on a branch. Give --head BRANCH.")
+    return branch
+
+
+def check_pushed(repo_dir: Path, remote: str, branch: str) -> None:
+    """Raise HandoffError if remote does not have the commit of the local branch."""
+    ref = f"refs/heads/{branch}"
+    local = git(repo_dir, "rev-parse", "--verify", ref)
+    lines = git(repo_dir, "ls-remote", remote, ref).splitlines()
+    remote_commits = [line.split()[0] for line in lines if line.split()[1:] == [ref]]
+    if remote_commits != [local]:
+        raise HandoffError(
+            f"{remote} does not have the local commit of {branch}. Push {branch} first."
+        )
+
+
+def find_open_pr(repo: str, head: str) -> str | None:
+    """Return the URL of the open pull request from head, or None if gh cannot find one."""
+    command = ["gh", "pr", "list", "--repo", repo, "--head", head, "--state", "open"]
+    command += ["--json", "url", "--jq", ".[0].url // empty"]
+    try:
+        result = subprocess.run(command, capture_output=True, encoding="utf-8", timeout=60)
+    except (OSError, subprocess.TimeoutExpired):
+        return None
+    url = result.stdout.strip()
+    return url if result.returncode == 0 and url else None
+
+
+def pr_request(args: argparse.Namespace) -> Request:
+    """Make the request that opens the GitHub form for a new pull request."""
+    repo_dir: Path = args.repo_dir
+    repo: str = args.repo or github_repo(git(repo_dir, "remote", "get-url", args.remote))
+    if not REPO_RE.fullmatch(repo):
+        raise HandoffError(f"{repo!r} is not a repository name of the form OWNER/NAME.")
+    head: str = args.head or current_branch(repo_dir)
+    check_pushed(repo_dir, args.remote, head)
+    try:
+        body: str = args.body_file.read_text(encoding="utf-8") if args.body_file else args.body
+    except OSError as exc:
+        raise HandoffError(f"Cannot read {args.body_file}: {exc}") from exc
+    url = compare_url(repo, args.base, head, args.title, body)
+    return Request(f"Create pull request: {args.title}", url, PR_STEPS, pr=(repo, head))
+
+
+class Dialog:
+    """A small window on top of the browser. It shows the steps and records the answer."""
+
+    def __init__(self, request: Request, timeout_minutes: float, reopen: Callable[[], None]):
+        self.status = "not_done"
+        self.note_text = ""
+        if sys.platform == "win32":
+            # Draw sharp text on high-DPI screens, as IDLE does. The call fails if the
+            # process has already set the DPI awareness. That is not a problem.
+            with contextlib.suppress(OSError):
+                ctypes.OleDLL("shcore").SetProcessDpiAwareness(1)
+        self.root = tk.Tk()
+        scale = self.root.winfo_fpixels("1i") / 96
+        wrap = round(420 * scale)
+        self.root.title(f"Claude handoff: {request.title}")
+        self.root.attributes("-topmost", True)
+        self.root.resizable(False, False)
+        # Put the window in the bottom-left corner, above the taskbar. Deploy pages
+        # usually put the final button on the right side.
+        self.root.geometry(f"+{round(24 * scale)}-{round(80 * scale)}")
+        self.root.protocol("WM_DELETE_WINDOW", lambda: self.finish("not_done"))
+
+        parts = urlsplit(request.url)
+        shown = f"https://{parts.netloc}{parts.path}"
+        if len(shown) > 80:
+            shown = shown[:77] + "..."
+        steps = "\n".join(f"{number}. {step}" for number, step in enumerate(request.steps, 1))
+        # Tk deletes a font when its Python object goes away, so the dialog keeps it.
+        self.bold = tkfont.nametofont("TkDefaultFont").copy()
+        self.bold.configure(weight="bold")
+        frame = ttk.Frame(self.root, padding=round(12 * scale))
+        frame.grid()
+        # The user must see first what to do, and then where to do it.
+        ttk.Label(frame, text=request.title, font=self.bold, wraplength=wrap).grid(sticky="w")
+        ttk.Label(frame, text=HEADING, font=self.bold).grid(sticky="w", pady=(8, 0))
+        ttk.Label(frame, text=steps, wraplength=wrap, justify="left").grid(sticky="w")
+        site = ttk.Label(frame, text=f"Site: {parts.hostname}", font=self.bold)
+        site.grid(sticky="w", pady=(8, 0))
+        link = ttk.Label(frame, text=shown, foreground="blue", cursor="hand2", wraplength=wrap)
+        link.grid(sticky="w")
+        link.bind("<Button-1>", lambda _event: reopen())
+        for text, color in ((FINISH, ""), (WARNING, "#b00020")):
+            label = ttk.Label(frame, text=text, foreground=color, wraplength=wrap, justify="left")
+            label.grid(sticky="w", pady=(8, 0))
+        ttk.Label(frame, text="Note for Claude (optional):").grid(sticky="w", pady=(8, 0))
+        self.note = ttk.Entry(frame)
+        self.note.grid(sticky="ew")
+        buttons = ttk.Frame(frame)
+        buttons.grid(sticky="e", pady=(12, 0))
+        self.done_button = ttk.Button(buttons, text="Done", command=lambda: self.finish("done"))
+        self.done_button.grid(row=0, column=0, padx=(0, 8))
+        self.not_done_button = ttk.Button(
+            buttons, text="Not done", command=lambda: self.finish("not_done")
+        )
+        self.not_done_button.grid(row=0, column=1)
+        self.timer = self.root.after(round(timeout_minutes * 60_000), self.finish, "timeout")
+
+    def finish(self, status: str) -> None:
+        """Record the answer and close the window."""
+        self.root.after_cancel(self.timer)
+        self.status = status
+        self.note_text = self.note.get().strip()
+        self.root.destroy()
+
+    def run(self) -> tuple[str, str]:
+        """Show the window until the user answers or the time ends."""
+        self.root.mainloop()
+        return self.status, self.note_text
+
+
+def parse_args(argv: list[str] | None) -> argparse.Namespace:
+    """Read the command line."""
+    common = argparse.ArgumentParser(add_help=False)
+    common.add_argument(
+        "--timeout",
+        type=float,
+        default=30,
+        metavar="MINUTES",
+        help="the time to wait for the user (default: 30)",
+    )
+    parser = argparse.ArgumentParser(
+        description="Open a web page and wait while the user does the final step."
+    )
+    commands = parser.add_subparsers(dest="command", required=True)
+    page = commands.add_parser("open", parents=[common], help="open a page and show the steps")
+    page.add_argument("--url", required=True, help="the https URL of the page")
+    page.add_argument("--title", required=True, help="the action, for example: Publish 1.2")
+    page.add_argument(
+        "--step", action="append", required=True, help="one step for the user (give it again)"
+    )
+    page.add_argument(
+        "--no-open", action="store_true", help="show only the dialog: the page is already open"
+    )
+    pr = commands.add_parser("pr", parents=[common], help="open the GitHub pull request form")
+    pr.add_argument("--title", required=True, help="the title of the pull request")
+    body = pr.add_mutually_exclusive_group()
+    body.add_argument("--body", default="", help="the description of the pull request")
+    body.add_argument("--body-file", type=Path, help="a file with the description")
+    pr.add_argument("--base", help="the branch to merge into (default: the default branch)")
+    pr.add_argument("--head", help="the branch to merge (default: the current branch)")
+    pr.add_argument("--repo", help="OWNER/NAME on GitHub (default: from the remote URL)")
+    pr.add_argument("--remote", default="origin", help="the remote with the branch")
+    pr.add_argument("--repo-dir", type=Path, default=Path(), help="the local repository")
+    return parser.parse_args(argv)
+
+
+def report(result: dict[str, str | None]) -> int:
+    """Print result as JSON. Return the exit code for its status."""
+    print(json.dumps(result))
+    return EXIT_CODES[str(result["status"])]
+
+
+def main(argv: list[str] | None = None) -> int:
+    """Do the handoff. Return the exit code."""
+    args = parse_args(argv)
+    try:
+        hosts = allowed_hosts()
+        if args.command == "pr":
+            request = pr_request(args)
+        else:
+            check_text(args.title, args.step)
+            request = Request(args.title, args.url, tuple(args.step))
+        check_url(request.url, hosts)
+        # With --no-open, the caller drove a browser to the page already.
+        if args.command == "pr" or not args.no_open:
+            open_page(request.url)
+    except HandoffError as exc:
+        return report({"status": "error", "error": str(exc)})
+    status, note = Dialog(request, args.timeout, lambda: open_page(request.url)).run()
+    result: dict[str, str | None] = {"status": status, "note": note}
+    if request.pr is not None and status == "done":
+        result["pr_url"] = find_open_pr(*request.pr)
+    return report(result)
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/skills/deploy-handoff/test_handoff.py b/skills/deploy-handoff/test_handoff.py
new file mode 100644
index 0000000..e4b7471
--- /dev/null
+++ b/skills/deploy-handoff/test_handoff.py
@@ -0,0 +1,460 @@
+"""Tests for handoff.py. README.md tells how to run them.
+
+The dialog tests show small windows for a short time.
+"""
+
+import json
+import subprocess
+import tempfile
+import tkinter as tk
+import unittest
+from contextlib import redirect_stdout
+from io import StringIO
+from pathlib import Path
+from tkinter import ttk
+from typing import TypeVar
+from unittest import mock
+from urllib.parse import parse_qs, unquote, urlsplit
+
+from hypothesis import assume, given
+from hypothesis import strategies as st
+
+import handoff
+from handoff import HandoffError, Request
+
+T = TypeVar("T")
+
+DOMAINS = ["github.com", "stripe.com", "google.com", "play.google.com"]
+LABEL = st.from_regex(r"[a-z0-9-]{1,12}", fullmatch=True)
+DOMAIN = st.lists(LABEL, min_size=2, max_size=3).map(".".join)
+ALLOWED_HOST = st.builds(
+    lambda prefix, domain: ".".join([*prefix, domain]),
+    st.lists(LABEL, max_size=3),
+    st.sampled_from(DOMAINS),
+)
+# A path and a query in printable ASCII. They contain "@" and ":", which must not change the host.
+REST = st.from_regex(
+    r"(/[A-Za-z0-9._~%!$&'()*+,;=:@-]*)*(\?[A-Za-z0-9._~%!$&'()*+,;=:@/?-]*)?", fullmatch=True
+)
+ALLOWED_URL = st.builds(lambda host, rest: f"https://{host}{rest}", ALLOWED_HOST, REST)
+TEXT = st.text(st.characters(codec="utf-8"), max_size=100)
+REPO = st.from_regex(r"[A-Za-z0-9-]{1,10}/[A-Za-z0-9_-]{1,10}", fullmatch=True)
+OWNER = st.from_regex(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,10}[A-Za-z0-9])?", fullmatch=True)
+NAME = st.from_regex(r"[A-Za-z0-9._-]{1,12}", fullmatch=True).filter(
+    lambda name: name not in {".", ".."} and not name.endswith(".git")
+)
+REMOTE_FORMS = [
+    "https://github.com/{}.git",
+    "https://github.com/{}",
+    "https://github.com/{}/",
+    "https://user:token@github.com/{}.git",
+    "git@github.com:{}.git",
+    "git@github.com:{}",
+    "ssh://git@github.com/{}.git",
+]
+WORD = st.from_regex(r"[A-Za-z0-9\"'.,:()-]{1,10}", fullmatch=True)
+# Characters that git allows in a branch name, other than "/". See git check-ref-format.
+REF_CHAR = st.characters(codec="utf-8", exclude_categories=["Cc"], exclude_characters=" ~^:?*[\\/")
+
+
+@st.composite
+def branches(draw: st.DrawFn) -> str:
+    """Make a branch name that git accepts."""
+    parts = draw(st.lists(st.text(REF_CHAR, min_size=1, max_size=6), min_size=1, max_size=3))
+    name = "/".join(parts)
+    assume(".." not in name and "@{" not in name and name != "@" and not name.endswith(".lock"))
+    assume(not any(part.startswith(".") or part.endswith(".") for part in parts))
+    return name
+
+
+@st.composite
+def host_and_domains(draw: st.DrawFn) -> tuple[str, list[str]]:
+    """Make a host near an allowed domain: the domain, a subdomain, or a look-alike."""
+    domains = draw(st.lists(DOMAIN, min_size=1, max_size=4))
+    domain = draw(st.sampled_from(domains))
+    label = draw(LABEL)
+    glue = draw(st.sampled_from([".", "", "-"]))
+    host = draw(st.sampled_from([domain, label + glue + domain, domain + glue + label]))
+    return host, domains
+
+
+def reference_match(host: str, domains: list[str]) -> str | None:
+    """Do the work of match_domain slowly: compare the labels from the right."""
+    labels = host.split(".")
+    found = [d for d in domains if labels[-len(d.split(".")) :] == d.split(".")]
+    return max(found, key=len, default=None)
+
+
+def label_texts(widget: tk.Misc) -> list[str]:
+    """Return the text of each label in widget, in the order in which the dialog made them."""
+    texts: list[str] = []
+    for child in widget.winfo_children():
+        if isinstance(child, ttk.Label):
+            texts.append(str(child.cget("text")))
+        texts.extend(label_texts(child))
+    return texts
+
+
+def run_git(*args: str) -> None:
+    identity = ["-c", "user.name=test", "-c", "user.email=test@example.com"]
+    subprocess.run(["git", *identity, *args], check=True, capture_output=True)
+
+
+def make_repo(root: Path) -> Path:
+    """Make a repository with one commit on main, and a bare remote with the name origin."""
+    remote, work = root / "remote.git", root / "work"
+    run_git("init", "--bare", str(remote))
+    run_git("init", "-b", "main", str(work))
+    run_git("-C", str(work), "commit", "--allow-empty", "-m", "first")
+    run_git("-C", str(work), "remote", "add", "origin", str(remote))
+    return work
+
+
+class MatchDomainTest(unittest.TestCase):
+    @given(host_and_domains())
+    def test_same_as_reference(self, case: tuple[str, list[str]]) -> None:
+        host, domains = case
+        self.assertEqual(handoff.match_domain(host, domains), reference_match(host, domains))
+
+    @given(st.lists(LABEL, max_size=3), DOMAIN)
+    def test_allows_subdomains(self, prefix: list[str], domain: str) -> None:
+        self.assertEqual(handoff.match_domain(".".join([*prefix, domain]), [domain]), domain)
+
+    @given(LABEL, DOMAIN)
+    def test_refuses_look_alikes(self, label: str, domain: str) -> None:
+        self.assertIsNone(handoff.match_domain(label + domain, [domain]))
+
+    def test_most_specific_domain_wins(self) -> None:
+        self.assertEqual(handoff.match_domain("a.play.google.com", DOMAINS), "play.google.com")
+
+
+class CheckUrlTest(unittest.TestCase):
+    @given(ALLOWED_HOST, REST)
+    def test_accepts_allowed_hosts(self, host: str, rest: str) -> None:
+        domain = handoff.match_domain(host, DOMAINS)
+        self.assertEqual(handoff.check_url(f"https://{host}{rest}", DOMAINS), domain)
+
+    @given(
+        ALLOWED_URL,
+        st.integers(min_value=0),
+        st.sampled_from(["\\", " ", "\t", "\n", "\x00", "\x7f", "\u00e9", "\u3002", "\u200b"]),
+    )
+    def test_refuses_unsafe_characters(self, url: str, index: int, char: str) -> None:
+        index %= len(url) + 1
+        with self.assertRaises(HandoffError):
+            handoff.check_url(url[:index] + char + url[index:], DOMAINS)
+
+    @given(ALLOWED_HOST, st.from_regex(r"[A-Za-z0-9._~!$&'()*+,;=:-]{0,12}", fullmatch=True))
+    def test_refuses_user_info(self, host: str, user: str) -> None:
+        with self.assertRaises(HandoffError):
+            handoff.check_url(f"https://{user}@{host}/", DOMAINS)
+
+    @given(ALLOWED_HOST, st.integers(min_value=0, max_value=65535))
+    def test_refuses_ports(self, host: str, port: int) -> None:
+        with self.assertRaises(HandoffError):
+            handoff.check_url(f"https://{host}:{port}/", DOMAINS)
+
+    def test_refuses_known_attacks(self) -> None:
+        for url in [
+            "",
+            "http://github.com/",
+            "https://evil.com/",
+            "https://evilgithub.com/",
+            "https://github.com.evil.com/",
+            "https://github.com@evil.com/",
+            "https://evil.com\\@github.com/",
+            "https://evil.com\\.github.com/",
+            "https://evil.com%2F.github.com/",
+            "https://github.com./",
+            "https://gith\u0443b.com/",
+            "https:github.com/",
+            "https:///github.com/",
+            "https://[::1]/",
+            "https://[github.com/",
+            "//github.com/",
+            "javascript:alert(1)//github.com/",
+        ]:
+            with self.subTest(url=url), self.assertRaises(HandoffError):
+                handoff.check_url(url, DOMAINS)
+
+    def test_ignores_the_case_of_the_host(self) -> None:
+        self.assertEqual(handoff.check_url("https://GitHub.COM/equwal", DOMAINS), "github.com")
+
+
+class CheckTextTest(unittest.TestCase):
+    @given(st.lists(WORD, min_size=1, max_size=20))
+    def test_accepts_short_text(self, words: list[str]) -> None:
+        handoff.check_text(" ".join(words), [" ".join(words)])
+
+    @given(st.lists(WORD, min_size=21, max_size=40))
+    def test_refuses_long_steps(self, words: list[str]) -> None:
+        with self.assertRaises(HandoffError):
+            handoff.check_text("Publish 1.4.0", ["Click it.", " ".join(words)])
+
+    def test_refuses_long_titles_semicolons_and_empty_text(self) -> None:
+        for title, steps in [
+            (" ".join(["word"] * 21), ["Click it."]),
+            ("Publish 1.4.0", ["Open the page; click it."]),
+            ("Publish 1.4.0", ["   "]),
+            ("", ["Click it."]),
+        ]:
+            with self.subTest(title=title, steps=steps), self.assertRaises(HandoffError):
+                handoff.check_text(title, steps)
+
+
+class ConfigTest(unittest.TestCase):
+    def setUp(self) -> None:
+        folder = tempfile.TemporaryDirectory()
+        self.addCleanup(folder.cleanup)
+        self.folder = Path(folder.name)
+
+    def write(self, text: str) -> Path:
+        path = self.folder / "config.toml"
+        path.write_text(text, encoding="utf-8")
+        return path
+
+    def test_shipped_file_allows_github(self) -> None:
+        self.assertIn("github.com", handoff.read_hosts(handoff.SHIPPED_CONFIG))
+
+    def test_refuses_bad_files(self) -> None:
+        for text in [
+            'allowed_hosts = ["https://github.com"]\n',
+            'allowed_hosts = ["GitHub.com"]\n',
+            "allowed_hosts = [1]\n",
+            'allowed_hosts = "github.com"\n',
+            "not toml\n",
+        ]:
+            with self.subTest(text=text), self.assertRaises(HandoffError):
+                handoff.read_hosts(self.write(text))
+
+    def test_refuses_a_missing_file(self) -> None:
+        with self.assertRaises(HandoffError):
+            handoff.read_hosts(self.folder / "missing.toml")
+
+    def test_user_file_adds_hosts(self) -> None:
+        user = self.write('allowed_hosts = ["example.com"]\n')
+        with mock.patch.object(handoff, "USER_CONFIG", user):
+            hosts = handoff.allowed_hosts()
+        self.assertIn("example.com", hosts)
+        self.assertIn("github.com", hosts)
+
+    def test_user_file_is_optional(self) -> None:
+        with mock.patch.object(handoff, "USER_CONFIG", self.folder / "missing.toml"):
+            self.assertEqual(handoff.allowed_hosts(), handoff.read_hosts(handoff.SHIPPED_CONFIG))
+
+
+class OpenPageTest(unittest.TestCase):
+    def test_opens_a_new_tab(self) -> None:
+        with mock.patch("webbrowser.open", return_value=True) as browser:
+            handoff.open_page("https://github.com/")
+        browser.assert_called_once_with("https://github.com/", new=2)
+
+    def test_error_if_no_browser_opens(self) -> None:
+        with mock.patch("webbrowser.open", return_value=False), self.assertRaises(HandoffError):
+            handoff.open_page("https://github.com/")
+
+
+class GithubRepoTest(unittest.TestCase):
+    @given(OWNER, NAME, st.sampled_from(REMOTE_FORMS))
+    def test_round_trip(self, owner: str, name: str, form: str) -> None:
+        self.assertEqual(handoff.github_repo(form.format(f"{owner}/{name}")), f"{owner}/{name}")
+
+    def test_refuses_other_remotes(self) -> None:
+        for remote in [
+            "https://gitlab.com/o/r.git",
+            "git@github-work:o/r.git",
+            "https://github.com.evil.com/o/r",
+            "https://github.com/o",
+            "https://github.com/o/r/extra",
+            "C:/repos/r.git",
+        ]:
+            with self.subTest(remote=remote), self.assertRaises(HandoffError):
+                handoff.github_repo(remote)
+
+
+class CompareUrlTest(unittest.TestCase):
+    @given(REPO, st.none() | branches(), branches(), TEXT, TEXT)
+    def test_round_trip(
+        self, repo: str, base: str | None, head: str, title: str, body: str
+    ) -> None:
+        url = handoff.compare_url(repo, base, head, title, body)
+        self.assertEqual(handoff.check_url(url, ["github.com"]), "github.com")
+        parts = urlsplit(url)
+        prefix = f"/{repo}/compare/"
+        self.assertTrue(parts.path.startswith(prefix))
+        refs = [unquote(ref) for ref in parts.path.removeprefix(prefix).split("...")]
+        self.assertEqual(refs, [head] if base is None else [base, head])
+        query = parse_qs(parts.query, keep_blank_values=True, strict_parsing=True)
+        self.assertEqual(query, {"expand": ["1"], "title": [title], "body": [body]})
+
+    def test_refuses_long_urls(self) -> None:
+        with self.assertRaises(HandoffError):
+            handoff.compare_url("o/r", None, "b", "t", "x" * handoff.MAX_PR_URL_LENGTH)
+
+
+class GitTest(unittest.TestCase):
+    def setUp(self) -> None:
+        folder = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+        self.addCleanup(folder.cleanup)
+        self.work = make_repo(Path(folder.name))
+
+    def test_current_branch(self) -> None:
+        self.assertEqual(handoff.current_branch(self.work), "main")
+        run_git("-C", str(self.work), "checkout", "--detach")
+        with self.assertRaises(HandoffError):
+            handoff.current_branch(self.work)
+
+    def test_check_pushed(self) -> None:
+        with self.assertRaises(HandoffError):
+            handoff.check_pushed(self.work, "origin", "main")
+        run_git("-C", str(self.work), "push", "origin", "main")
+        handoff.check_pushed(self.work, "origin", "main")
+        run_git("-C", str(self.work), "commit", "--allow-empty", "-m", "second")
+        with self.assertRaises(HandoffError):
+            handoff.check_pushed(self.work, "origin", "main")
+
+
+class DialogTest(unittest.TestCase):
+    def make_dialog(self, timeout_minutes: float = 1) -> handoff.Dialog:
+        request = Request("Test handoff", "https://github.com/equwal", ("Look at the page.",))
+        return handoff.Dialog(request, timeout_minutes, lambda: None)
+
+    def test_done_returns_the_note(self) -> None:
+        dialog = self.make_dialog()
+
+        def answer() -> None:
+            dialog.note.insert(0, "  shipped  ")
+            dialog.done_button.invoke()
+
+        dialog.root.after(50, answer)
+        self.assertEqual(dialog.run(), ("done", "shipped"))
+
+    def test_not_done(self) -> None:
+        dialog = self.make_dialog()
+        dialog.root.after(50, dialog.not_done_button.invoke)
+        self.assertEqual(dialog.run(), ("not_done", ""))
+
+    def test_closing_the_window_means_not_done(self) -> None:
+        dialog = self.make_dialog()
+        close = dialog.root.protocol("WM_DELETE_WINDOW")
+        dialog.root.after(50, dialog.root.tk.call, close)
+        self.assertEqual(dialog.run(), ("not_done", ""))
+
+    def test_timeout(self) -> None:
+        self.assertEqual(self.make_dialog(timeout_minutes=0.001).run(), ("timeout", ""))
+
+    def test_shows_the_steps_under_a_heading(self) -> None:
+        # The smoke test on 2026-09-21 showed this request. The user could not see what to do.
+        request = Request(
+            "Smoke test: deploy-handoff",
+            "https://github.com/equwal",
+            ("No action needed. This window closes itself.",),
+        )
+        dialog = handoff.Dialog(request, 1, lambda: None)
+        texts = label_texts(dialog.root)
+        dialog.finish("not_done")
+        expected = [
+            "Smoke test: deploy-handoff",
+            "Do these steps in your browser:",
+            "1. No action needed. This window closes itself.",
+        ]
+        self.assertEqual(texts[:3], expected)
+        self.assertIn("Site: github.com", texts)
+
+
+class MainTest(unittest.TestCase):
+    """Test main with a fake browser and a fake dialog."""
+
+    def setUp(self) -> None:
+        folder = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+        self.addCleanup(folder.cleanup)
+        self.root = Path(folder.name)
+        config = self.root / "config.toml"
+        config.write_text('allowed_hosts = ["github.com"]\n', encoding="utf-8")
+        self.patch("SHIPPED_CONFIG", config)
+        self.patch("USER_CONFIG", self.root / "missing.toml")
+        self.open_page = self.patch("open_page", mock.MagicMock())
+        self.dialog = self.patch("Dialog", mock.MagicMock())
+        self.dialog.return_value.run.return_value = ("done", "ok")
+
+    def patch(self, name: str, value: T) -> T:
+        patcher = mock.patch.object(handoff, name, value)
+        patcher.start()
+        self.addCleanup(patcher.stop)
+        return value
+
+    def run_main(self, *argv: str) -> tuple[int, dict[str, object]]:
+        output = StringIO()
+        with redirect_stdout(output):
+            code = handoff.main(list(argv))
+        result: dict[str, object] = json.loads(output.getvalue())
+        return code, result
+
+    def open_url(self, url: str) -> tuple[int, dict[str, object]]:
+        return self.run_main("open", "--url", url, "--title", "Do it", "--step", "Click it.")
+
+    def test_open(self) -> None:
+        self.assertEqual(
+            self.open_url("https://github.com/o"), (0, {"status": "done", "note": "ok"})
+        )
+        self.open_page.assert_called_once_with("https://github.com/o")
+
+    def test_exit_codes_for_other_answers(self) -> None:
+        for status, expected in [("not_done", 3), ("timeout", 4)]:
+            self.dialog.return_value.run.return_value = (status, "")
+            code, result = self.open_url("https://github.com/o")
+            self.assertEqual((code, result["status"]), (expected, status))
+
+    def test_no_open_shows_only_the_dialog(self) -> None:
+        code, result = self.run_main(
+            "open",
+            "--no-open",
+            "--url",
+            "https://github.com/o",
+            "--title",
+            "Do it",
+            "--step",
+            "Go.",
+        )
+        self.assertEqual((code, result["status"]), (0, "done"))
+        self.open_page.assert_not_called()
+        self.dialog.assert_called_once()
+
+    def test_open_refuses_long_steps(self) -> None:
+        code, result = self.run_main(
+            "open", "--url", "https://github.com/o", "--title", "Do it", "--step", "word " * 21
+        )
+        self.assertEqual((code, result["status"]), (1, "error"))
+        self.open_page.assert_not_called()
+
+    def test_open_refuses_hosts_that_are_not_allowed(self) -> None:
+        code, result = self.open_url("https://evil.example/")
+        self.assertEqual((code, result["status"]), (1, "error"))
+        self.open_page.assert_not_called()
+        self.dialog.assert_not_called()
+
+    def test_pr(self) -> None:
+        work = make_repo(self.root)
+        run_git("-C", str(work), "push", "origin", "main")
+        find_open_pr = self.patch("find_open_pr", mock.MagicMock())
+        find_open_pr.return_value = "https://github.com/o/r/pull/1"
+        code, result = self.run_main(
+            "pr", "--repo", "o/r", "--repo-dir", str(work), "--title", "Add X", "--body", "Why"
+        )
+        expected = {"status": "done", "note": "ok", "pr_url": "https://github.com/o/r/pull/1"}
+        self.assertEqual((code, result), (0, expected))
+        self.open_page.assert_called_once_with(
+            "https://github.com/o/r/compare/main?expand=1&title=Add%20X&body=Why"
+        )
+        find_open_pr.assert_called_once_with("o/r", "main")
+
+    def test_pr_refuses_a_branch_that_is_not_pushed(self) -> None:
+        work = make_repo(self.root)
+        code, result = self.run_main("pr", "--repo", "o/r", "--repo-dir", str(work), "--title", "X")
+        self.assertEqual((code, result["status"]), (1, "error"))
+        self.assertIn("Push main first", str(result["error"]))
+        self.open_page.assert_not_called()
+
+
+if __name__ == "__main__":
+    unittest.main()