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)