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 1d1f6668c46fd5d1ae703de78bfc6031b059b161
equwal <13551856+equwal@users.noreply.github.com>
2026-10-03 19:13:19 -0700

Show the steps in bemenu when Python has no Tk

Linux desktop g runs Wayland without X11, so its Python has no tkinter and handoff.py crashed at import. The tkinter import is now optional. Without Tk, MenuDialog lists the steps in bemenu: Done and Not done answer, typed text becomes the note for Not done, Escape means Not done, and the timeout still applies.

 skills/deploy-handoff/SKILL.md        |  2 +-
 skills/deploy-handoff/handoff.py      | 63 ++++++++++++++++++++++++++++++--
 skills/deploy-handoff/test_handoff.py | 69 +++++++++++++++++++++++++++++++++++
 3 files changed, 129 insertions(+), 5 deletions(-)
diff --git a/skills/deploy-handoff/SKILL.md b/skills/deploy-handoff/SKILL.md
index bac242c..d0cdfc8 100644
--- a/skills/deploy-handoff/SKILL.md
+++ b/skills/deploy-handoff/SKILL.md
@@ -16,7 +16,7 @@ description: >-
 
 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.
+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. If Python has no Tk, for example on a Wayland desktop without X11, the script shows the steps in a `bemenu` list instead.
 
 ## 0. Select the automation mode
 
diff --git a/skills/deploy-handoff/handoff.py b/skills/deploy-handoff/handoff.py
index a4ccf13..e597666 100644
--- a/skills/deploy-handoff/handoff.py
+++ b/skills/deploy-handoff/handoff.py
@@ -26,17 +26,24 @@ import shutil
 import subprocess
 import sys
 import time
-import tkinter as tk
 import tomllib
 import urllib.request
 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
 
+# A Wayland desktop without X11 has no Tk. Then MenuDialog shows the steps in bemenu.
+try:
+    import tkinter as tk
+    from tkinter import font as tkfont
+    from tkinter import ttk
+
+    HAVE_TK = True
+except ImportError:
+    HAVE_TK = False
+
 SHIPPED_CONFIG = Path(__file__).with_name("config.toml")
 USER_CONFIG = (
     Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
@@ -513,6 +520,52 @@ class Dialog:
         return self.status, self.note_text
 
 
+class MenuDialog:
+    """The steps in a bemenu list, for a Wayland desktop that has no Tk."""
+
+    DONE = "Done"
+    NOT_DONE = "Not done"
+
+    def __init__(self, request: Request, timeout_minutes: float, reopen: Callable[[], None]):
+        if shutil.which("bemenu") is None:
+            raise HandoffError("Cannot show the steps: install Python Tk or bemenu.")
+        self.title = request.title
+        self.deadline = time.monotonic() + timeout_minutes * 60
+        steps = [f"{number}. {step}" for number, step in enumerate(request.steps, 1)]
+        self.items = [
+            *steps,
+            f"Site: {urlsplit(request.url).hostname}",
+            "If you cannot finish, type the reason and press Enter.",
+            self.DONE,
+            self.NOT_DONE,
+        ]
+
+    def run(self) -> tuple[str, str]:
+        """Show the list until the user selects an answer or the time ends."""
+        while (remaining := self.deadline - time.monotonic()) > 0:
+            command = ["bemenu", "--list", str(len(self.items)), "--prompt", self.title]
+            try:
+                answer = subprocess.run(
+                    command,
+                    input="\n".join(self.items),
+                    capture_output=True,
+                    text=True,
+                    timeout=remaining,
+                )
+            except subprocess.TimeoutExpired:
+                break
+            choice = answer.stdout.strip()
+            # Escape makes bemenu exit with code 1.
+            if answer.returncode != 0 or choice == self.NOT_DONE:
+                return "not_done", ""
+            if choice == self.DONE:
+                return "done", ""
+            if choice not in self.items:
+                return "not_done", choice
+            # The user selected a step or the site. Show the list again.
+        return "timeout", ""
+
+
 def parse_args(argv: list[str] | None) -> argparse.Namespace:
     """Read the command line."""
     common = argparse.ArgumentParser(add_help=False)
@@ -584,9 +637,11 @@ def main(argv: list[str] | None = None) -> int:
         # 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)
+        show = Dialog if HAVE_TK else MenuDialog
+        dialog = show(request, args.timeout, lambda: 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()
+    status, note = dialog.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)
diff --git a/skills/deploy-handoff/test_handoff.py b/skills/deploy-handoff/test_handoff.py
index 645c83c..86ff32d 100644
--- a/skills/deploy-handoff/test_handoff.py
+++ b/skills/deploy-handoff/test_handoff.py
@@ -647,6 +647,66 @@ class DialogTest(unittest.TestCase):
         self.assertIn("Site: github.com", texts)
 
 
+class MenuDialogTest(unittest.TestCase):
+    """Test the bemenu dialog with a fake bemenu."""
+
+    def setUp(self) -> None:
+        which = mock.patch("shutil.which", return_value="/usr/bin/bemenu")
+        which.start()
+        self.addCleanup(which.stop)
+        self.request = Request("Publish 1.2", "https://github.com/o", ("Click it.",))
+
+    def answer(self, *results: subprocess.CompletedProcess[str] | Exception) -> mock.MagicMock:
+        run = mock.MagicMock(side_effect=list(results))
+        patcher = mock.patch("subprocess.run", run)
+        patcher.start()
+        self.addCleanup(patcher.stop)
+        return run
+
+    def pick(self, choice: str, code: int = 0) -> subprocess.CompletedProcess[str]:
+        return subprocess.CompletedProcess(["bemenu"], code, stdout=choice + "\n")
+
+    def run_menu(self, timeout_minutes: float = 1) -> tuple[str, str]:
+        return handoff.MenuDialog(self.request, timeout_minutes, lambda: None).run()
+
+    def test_done(self) -> None:
+        run = self.answer(self.pick("Done"))
+        self.assertEqual(self.run_menu(), ("done", ""))
+        command = run.call_args.args[0]
+        self.assertEqual(command[0], "bemenu")
+        self.assertIn("Publish 1.2", command)
+        self.assertIn("1. Click it.", run.call_args.kwargs["input"].splitlines())
+        self.assertIn("Site: github.com", run.call_args.kwargs["input"].splitlines())
+
+    def test_not_done(self) -> None:
+        self.answer(self.pick("Not done"))
+        self.assertEqual(self.run_menu(), ("not_done", ""))
+
+    def test_escape_means_not_done(self) -> None:
+        self.answer(self.pick("", code=1))
+        self.assertEqual(self.run_menu(), ("not_done", ""))
+
+    def test_typed_text_is_the_note(self) -> None:
+        self.answer(self.pick("  no access  "))
+        self.assertEqual(self.run_menu(), ("not_done", "no access"))
+
+    def test_a_step_shows_the_list_again(self) -> None:
+        run = self.answer(self.pick("1. Click it."), self.pick("Done"))
+        self.assertEqual(self.run_menu(), ("done", ""))
+        self.assertEqual(run.call_count, 2)
+
+    def test_timeout(self) -> None:
+        self.answer(subprocess.TimeoutExpired(["bemenu"], 1))
+        self.assertEqual(self.run_menu(), ("timeout", ""))
+
+    def test_no_bemenu_is_an_error(self) -> None:
+        with (
+            mock.patch("shutil.which", return_value=None),
+            self.assertRaises(HandoffError),
+        ):
+            handoff.MenuDialog(self.request, 1, lambda: None)
+
+
 class MainTest(unittest.TestCase):
     """Test main with a fake browser and a fake dialog."""
 
@@ -705,6 +765,15 @@ class MainTest(unittest.TestCase):
         self.open_page.assert_not_called()
         self.dialog.assert_called_once()
 
+    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)
+        menu = self.patch("MenuDialog", mock.MagicMock())
+        menu.return_value.run.return_value = ("done", "")
+        self.assertEqual(self.open_url("https://github.com/o")[0], 0)
+        menu.assert_called_once()
+        self.dialog.assert_not_called()
+
     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