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 b5647f1113eeba7656dfe58d79de1d10defa4b9d
map588 <map588@pitt.edu>
2026-10-04 01:28:32 -0400

Add the terminal handoff: a dialog for a question in a terminal

Some human steps are not on a web page. A program asks a question in a
terminal and waits: an install question such as "Install ...? [Y/n]", the
sign-in of a command line program, a confirmation. The dialog could not show
such a step, because "open" needs a URL on an allowed host. In use on
2026-10-04, a sign-in in a terminal gave "claude.ai is not an allowed host",
and the user got the steps only as text in the chat.

"handoff.py terminal --title T --where W --step S" shows the same dialog with
the heading "Do these steps in the terminal:" and the line "Where: W". It
opens no page and checks no host. The skill tells the agent to start the
program in a terminal that the user sees, to stop at the question, never to
type the answer, and to wait for the result of the step.

The rule now names a question in a terminal as a human step, and says that a
list of commands for the user to copy and run is an error: the agent runs
each command and brings the user to the one step that needs a person.

 README.md                             | 11 ++++
 rules/deploy-handoff.md               |  7 ++-
 skills/deploy-handoff/SKILL.md        | 41 +++++++++++++--
 skills/deploy-handoff/handoff.py      | 99 ++++++++++++++++++++++++++---------
 skills/deploy-handoff/test_handoff.py | 87 ++++++++++++++++++++++++++++++
 5 files changed, 215 insertions(+), 30 deletions(-)
diff --git a/README.md b/README.md
index f313862..f7002ce 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,17 @@ python3 skills/deploy-handoff/handoff.py open \
 
 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.
 
+Show the steps for a question that a program asks in a terminal. The command opens no page and needs no allowed host:
+
+```bash
+python3 skills/deploy-handoff/handoff.py terminal \
+  --title "Install Herdr on the build server" \
+  --where 'Terminal tab "herdr: add build server"' \
+  --step 'Press "Y".'
+```
+
+Claude starts the program in a terminal that you see and stops at the question. You give the answer.
+
 Open the pull request form for the current branch:
 
 ```bash
diff --git a/rules/deploy-handoff.md b/rules/deploy-handoff.md
index d32cc4d..9e69127 100644
--- a/rules/deploy-handoff.md
+++ b/rules/deploy-handoff.md
@@ -52,8 +52,11 @@ Use the skill for these steps:
 - The creation of an account, an API key, a webhook secret, or another secret. Tell the user where to put the secret. Never ask for the secret in the chat or in the dialog.
 - A step that needs the explicit yes of the user, for example the switch to live payments.
 - A deploy command that a permission check denied. Do not run the command again. If the platform has a web page for the same step, hand off that page.
+- A question that a program asks in a terminal before it continues: an install question, the sign-in of a command line program, or a confirmation of a change that cannot be undone.
 
-If the step has no web page, ask the user in the chat.
+If the step is a question in a terminal, start the program in a terminal that the user sees, stop at the question, and use the terminal handoff of the skill (`handoff.py terminal`). Do not type the answer.
+
+If the step has no web page and no question in a terminal, ask the user in the chat.
 
 To hand off:
 
@@ -91,6 +94,8 @@ For these steps:
 
 Do not hand off a step that a command can do. A handoff for a command that you can run wastes the time of the user.
 
+Do not give the user a list of commands to copy and run. This is the same error. Work on a server through SSH is also a command: copy the build, install the package, restart the service. Run each command. Bring the user to the one step that needs a person, and stop there.
+
 On Windows, run `handoff.py` in Git Bash, not in Windows PowerShell 5.1. PowerShell 5.1 removes the double quotation marks in the arguments.
 
 ## Browser: go all the way to the last step
diff --git a/skills/deploy-handoff/SKILL.md b/skills/deploy-handoff/SKILL.md
index d0cdfc8..5e0748b 100644
--- a/skills/deploy-handoff/SKILL.md
+++ b/skills/deploy-handoff/SKILL.md
@@ -6,8 +6,10 @@ description: >-
   in, never a fresh profile. Then a dialog tells the user 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.
+  merge requests, hosting consoles. Also use it for a question that a program
+  asks in a terminal, for example an install question or the sign-in of a
+  command line program. Also use it to open every GitHub pull request, instead
+  of gh pr create or another pull request tool.
 ---
 
 # deploy-handoff
@@ -28,6 +30,7 @@ Before you do the work, select one of these modes:
 |---|---|
 | PowerShell command | A CLI or API does the step, for example `vercel deploy`, `gh release create`, or `stripe`. Run it with the PowerShell tool. |
 | Brave with the Claude extension | The `mcp__claude-in-chrome__*` tools are available, and the extension runs in the Brave profile of the user. This is the default browser mode. |
+| Terminal prompt | The step is a question that a program asks in a terminal: an install question, a sign-in of a command line program, or a confirmation. See "Hand off a step in a terminal". |
 | Windows-MCP | The step is in a desktop app or an operating system dialog, not in a web page. |
 | Dialog with instructions only | No automation tool is available, or the user wants to do all the steps. Run `handoff.py open` without `--no-open`. The script opens the page in the Brave profile of the user. |
 | Fresh Brave profile with Playwright | Only if the user asks for it. The `browser-driver` agent uses `handoff.py browser --fresh` and the Playwright MCP. |
@@ -37,9 +40,10 @@ Guess the mode with these rules, in this order:
 
 1. If the user named a mode in this session, use it.
 2. If a command can do the step, use "PowerShell command".
-3. If the step is not in a web page, use "Windows-MCP".
-4. If the `mcp__claude-in-chrome__*` tools are available, use "Brave with the Claude extension".
-5. Otherwise, use "Dialog with instructions only".
+3. If the step is a question that a program asks in a terminal, use "Terminal prompt".
+4. If the step is not in a web page, use "Windows-MCP".
+5. If the `mcp__claude-in-chrome__*` tools are available, use "Brave with the Claude extension".
+6. Otherwise, use "Dialog with instructions only".
 
 If a mode fails, use "Dialog with instructions only". Do not go to a fresh browser unless the user asks for it. Tell the user in one sentence which mode you use.
 
@@ -135,6 +139,33 @@ Useful start pages:
 | F-Droid merge requests | `https://gitlab.com/fdroid/fdroiddata/-/merge_requests` |
 | GitHub device sign-in | `https://github.com/login/device` |
 
+## Hand off a step in a terminal
+
+Some human steps are not on a web page. A program asks a question in a terminal and waits. Examples: an install question such as `Install ...? [Y/n]`, the sign-in of a command line program, a confirmation before a change that cannot be undone. Bring the user to the question. The user gives the answer.
+
+1. Do the work that comes before the question with commands.
+2. Start the program in a terminal that the user can see and type in. In the Claude Code desktop app, use the tool that types a command into a tab of the Terminal panel. Give the tab a title that names the step. If you have no such tool, give the user the one command that starts the program.
+3. Read the terminal. Make sure that the program shows the question and waits.
+4. Do not type the answer. Do not give the answer in another way, for example with `yes |` or with a `--yes` option.
+5. Show the dialog. Run the command in the background:
+
+   ```bash
+   python3 SKILL_DIR/handoff.py terminal \
+     --title "Install Herdr on the build server" \
+     --where 'Terminal tab "herdr: add build server"' \
+     --step 'Press "Y".'
+   ```
+
+6. Wait for the result of the step, not only for the dialog. For example, run the command that lists the new item until the item is there. Then continue. A `done` answer with no result is not done.
+
+The `terminal` command opens no page, so it needs no URL and no allowed host. `--where` names the terminal in 20 words or fewer. The title and the steps follow section 2.
+
+More than one question can wait at the same time. Start each program in its own tab, and name each tab in its dialog.
+
+If the program asks for a password, a code, or a key, the user types it in the terminal. Never ask for the value in the chat.
+
+Do not give the user a list of commands to copy and run. A command that needs no answer from a person is your work: run it. Give the user only the question.
+
 ## Open a pull request
 
 Push the branch first. Then run this command in the repository:
diff --git a/skills/deploy-handoff/handoff.py b/skills/deploy-handoff/handoff.py
index e08a80e..6a6cd01 100644
--- a/skills/deploy-handoff/handoff.py
+++ b/skills/deploy-handoff/handoff.py
@@ -7,6 +7,11 @@ profile that brave_profile names in the user config file, or else in the default
 browser. It 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.
 
+The command "terminal" shows the same dialog for a step at a prompt in a terminal,
+for example the install question of a tool or a sign-in of a command line program.
+It opens no page. The caller starts the program in a terminal that the user sees,
+and stops at the prompt. The script never types the answer for the user.
+
 The command "browser --fresh" starts a separate browser with a fresh profile for
 the browser-driver agent of this plugin. Use it only if the user asks for it.
 Playwright MCP controls that browser through a local port.
@@ -69,6 +74,7 @@ PR_STEPS = (
     'Click "Create pull request".',
 )
 HEADING = "Do these steps in your browser:"
+TERMINAL_HEADING = "Do these steps in the terminal:"
 FINISH = (
     'When you finish, click "Done".\n'
     'If you cannot finish, write the reason in the note. Then click "Not done".'
@@ -77,6 +83,10 @@ 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."
 )
+TERMINAL_WARNING = (
+    "Claude does not type the answer for you. 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.
@@ -96,13 +106,22 @@ class HandoffError(Exception):
 
 @dataclass(frozen=True)
 class Request:
-    """A page to open, and the steps that the user must do on it."""
+    """A page to open, or a terminal, and the steps that the user must do there."""
 
     title: str
-    url: str
+    # None for a step in a terminal. Then place names the terminal.
+    url: str | None
     steps: tuple[str, ...]
     # For a pull request: the repository as "owner/name", and the head branch.
     pr: tuple[str, str] | None = None
+    # For a step in a terminal: where the prompt is, for example the name of a terminal tab.
+    place: str | None = None
+
+    def where(self) -> str:
+        """Return the line that tells the user where to do the steps."""
+        if self.url is None:
+            return f"Where: {self.place}"
+        return f"Site: {urlsplit(self.url).hostname}"
 
 
 def match_domain(host: str, domains: Iterable[str]) -> str | None:
@@ -146,6 +165,16 @@ def check_text(title: str, steps: Iterable[str]) -> None:
             )
 
 
+def check_place(place: str) -> None:
+    """Raise HandoffError if place does not name a terminal in a few printable words."""
+    words = len(place.split())
+    if not 0 < words <= MAX_STEP_WORDS or not place.isprintable():
+        raise HandoffError(
+            f"The place is not a short name of a terminal ({words} words). "
+            f"Give 1 to {MAX_STEP_WORDS} printable words, for example the name of the tab."
+        )
+
+
 def read_config(path: Path) -> dict[str, object]:
     """Return the data of the TOML file at path."""
     try:
@@ -472,10 +501,6 @@ class Dialog:
         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()
@@ -484,14 +509,23 @@ class Dialog:
         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))
+        heading = HEADING if request.url is not None else TERMINAL_HEADING
+        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")):
+        where = ttk.Label(frame, text=request.where(), font=self.bold, wraplength=wrap)
+        where.grid(sticky="w", pady=(8, 0))
+        warning = TERMINAL_WARNING
+        if request.url is not None:
+            # A step on a page: show the address, and open the page again on a click.
+            parts = urlsplit(request.url)
+            shown = f"https://{parts.netloc}{parts.path}"
+            if len(shown) > 80:
+                shown = shown[:77] + "..."
+            link = ttk.Label(frame, text=shown, foreground="blue", cursor="hand2", wraplength=wrap)
+            link.grid(sticky="w")
+            link.bind("<Button-1>", lambda _event: reopen())
+            warning = WARNING
+        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))
@@ -537,7 +571,7 @@ class MenuDialog:
         steps = [f"{number}. {step}" for number, step in enumerate(request.steps, 1)]
         self.items = [
             *steps,
-            f"Site: {urlsplit(request.url).hostname}",
+            request.where(),
             "If you cannot finish, type the reason and press Enter.",
             self.DONE,
             self.NOT_DONE,
@@ -592,6 +626,16 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
     page.add_argument(
         "--no-open", action="store_true", help="show only the dialog: the page is already open"
     )
+    terminal = commands.add_parser(
+        "terminal", parents=[common], help="show the steps for a prompt in a terminal"
+    )
+    terminal.add_argument("--title", required=True, help="the action, for example: Install 1.2")
+    terminal.add_argument(
+        "--where", required=True, help="where the prompt is, for example the name of the tab"
+    )
+    terminal.add_argument(
+        "--step", action="append", required=True, help="one step for the user (give it again)"
+    )
     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()
@@ -630,18 +674,25 @@ def main(argv: list[str] | None = None) -> int:
                     "use the Brave profile of the user."
                 )
             return report(run_browser(args.exe))
-        hosts = allowed_hosts()
-        if args.command == "pr":
-            request = pr_request(args)
-        else:
+        if args.command == "terminal":
+            # A step at a prompt in a terminal has no page: no host to check, nothing to open.
             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)
+            check_place(args.where)
+            request = Request(args.title, None, tuple(args.step), place=args.where)
+        else:
+            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(str(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(str(request.url))
+        url = request.url
         show = Dialog if HAVE_TK else MenuDialog
-        dialog = show(request, args.timeout, lambda: open_page(request.url))
+        dialog = show(request, args.timeout, lambda: open_page(url) if url else None)
     except HandoffError as exc:
         return report({"status": "error", "error": str(exc)})
     status, note = dialog.run()
diff --git a/skills/deploy-handoff/test_handoff.py b/skills/deploy-handoff/test_handoff.py
index 26223ca..c39533c 100644
--- a/skills/deploy-handoff/test_handoff.py
+++ b/skills/deploy-handoff/test_handoff.py
@@ -207,6 +207,17 @@ class CheckTextTest(unittest.TestCase):
                 handoff.check_text(title, steps)
 
 
+class CheckPlaceTest(unittest.TestCase):
+    @given(st.lists(WORD, min_size=1, max_size=handoff.MAX_STEP_WORDS))
+    def test_accepts_a_short_name(self, words: list[str]) -> None:
+        handoff.check_place(" ".join(words))
+
+    def test_refuses_empty_long_and_unprintable_names(self) -> None:
+        for place in ["", "   ", "word " * 21, "tab\x1b[2J", "one\ntwo"]:
+            with self.subTest(place=place), self.assertRaises(HandoffError):
+                handoff.check_place(place)
+
+
 class ConfigTest(unittest.TestCase):
     def setUp(self) -> None:
         folder = tempfile.TemporaryDirectory()
@@ -646,6 +657,33 @@ class DialogTest(unittest.TestCase):
         self.assertEqual(texts[:3], expected)
         self.assertIn("Site: github.com", texts)
 
+    def test_terminal_step_names_the_terminal_and_shows_no_page(self) -> None:
+        # On 2026-10-04 a tool asked "Install ...? [Y/n]" in a terminal tab. The step had no
+        # page, so the dialog could not show it: the script wanted a URL on an allowed host.
+        request = Request(
+            "Install Herdr on basedmatrix",
+            None,
+            ('Press "Y".',),
+            place='Terminal tab "herdr: add basedmatrix"',
+        )
+        dialog = handoff.Dialog(request, 1, lambda: None)
+        texts = label_texts(dialog.root)
+        # Let the window run before it closes: on macOS, Tk stops the process (SIGTRAP) when
+        # a second window that never ran is closed in one test process.
+        dialog.root.after(50, dialog.done_button.invoke)
+        self.assertEqual(dialog.run(), ("done", ""))
+        expected = [
+            "Install Herdr on basedmatrix",
+            "Do these steps in the terminal:",
+            '1. Press "Y".',
+            'Where: Terminal tab "herdr: add basedmatrix"',
+        ]
+        self.assertEqual(texts[:4], expected)
+        self.assertIn(handoff.TERMINAL_WARNING, texts)
+        # No site, no address, and no text about the address bar of a browser.
+        self.assertFalse([text for text in texts if "Site:" in text or "https://" in text])
+        self.assertNotIn(handoff.WARNING, texts)
+
 
 class MenuDialogTest(unittest.TestCase):
     """Test the bemenu dialog with a fake bemenu."""
@@ -681,6 +719,14 @@ class MenuDialogTest(unittest.TestCase):
         self.assertIn("1. Click it.", run.call_args.kwargs["input"].splitlines())
         self.assertIn("Site: github.com", run.call_args.kwargs["input"].splitlines())
 
+    def test_a_terminal_step_names_the_terminal(self) -> None:
+        self.request = Request("Log in", None, ('Type "/login".',), place="Terminal tab 2")
+        run = self.answer(self.pick("Done"))
+        self.assertEqual(self.run_menu(), ("done", ""))
+        lines = run.call_args.kwargs["input"].splitlines()
+        self.assertIn("Where: Terminal tab 2", lines)
+        self.assertFalse([line for line in lines if line.startswith("Site:")])
+
     def test_not_done(self) -> None:
         self.answer(self.pick("Not done"))
         self.assertEqual(self.run_menu(), ("not_done", ""))
@@ -776,6 +822,47 @@ class MainTest(unittest.TestCase):
         self.open_page.assert_not_called()
         self.dialog.assert_called_once()
 
+    def test_terminal_shows_the_dialog_and_opens_no_page(self) -> None:
+        # A sign-in of a command line program on 2026-10-04: the only page was claude.ai,
+        # which is not an allowed host, so "open" gave an error and the user got no dialog.
+        # A step in a terminal needs no host.
+        self.patch("SHIPPED_CONFIG", self.root / "no-hosts.toml")
+        code, result = self.run_main(
+            "terminal",
+            "--title",
+            "Log the agent user in to Claude Code",
+            "--where",
+            'Terminal tab "login vps"',
+            "--step",
+            'Type "/login".',
+            "--step",
+            "Follow the instructions on the screen.",
+        )
+        self.assertEqual((code, result), (0, {"status": "done", "note": "ok"}))
+        self.open_page.assert_not_called()
+        request = self.dialog.call_args.args[0]
+        self.assertEqual(
+            request,
+            Request(
+                "Log the agent user in to Claude Code",
+                None,
+                ('Type "/login".', "Follow the instructions on the screen."),
+                place='Terminal tab "login vps"',
+            ),
+        )
+        # The link of a page opens the page again. A terminal step has no page to open.
+        self.dialog.call_args.args[2]()
+        self.open_page.assert_not_called()
+
+    def test_terminal_refuses_bad_text(self) -> None:
+        for where, step in [("", "Press Y."), ("tab 1", "word " * 21), ("x\ty\x07", "Press Y.")]:
+            with self.subTest(where=where, step=step):
+                code, result = self.run_main(
+                    "terminal", "--title", "Do it", "--where", where, "--step", step
+                )
+                self.assertEqual((code, result["status"]), (1, "error"))
+        self.dialog.assert_not_called()
+
     def test_without_tk_uses_bemenu(self) -> None:
         # Linux desktop g runs Wayland without X11, so its Python has no tkinter.
         self.patch("HAVE_TK", False)