From f62457680008f690db1094750330aa0dd5da63ee Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 00:02:10 -0500 Subject: [PATCH 01/10] feat: scaffold menubar app with menu structure and URL opening Co-Authored-By: Claude Opus 4.6 --- scripts/menubar.py | 105 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100755 scripts/menubar.py diff --git a/scripts/menubar.py b/scripts/menubar.py new file mode 100755 index 0000000..63d7904 --- /dev/null +++ b/scripts/menubar.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Claude Code Remote — macOS menu bar app.""" + +import os +import signal +import subprocess +import rumps + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_DIR = os.path.dirname(SCRIPT_DIR) +LOG_DIR = os.path.join(PROJECT_DIR, "logs") + +# Icon states +ICON_GREEN = "● CC" +ICON_GRAY = "○ CC" +ICON_RED = "◉ CC" + + +class RemoteCLIApp(rumps.App): + def __init__(self): + super().__init__(ICON_GRAY, quit_button=None) + self.tailscale_ip = self._get_tailscale_ip() + + self.status_item = rumps.MenuItem("Status: Stopped") + self.status_item.set_callback(None) + + self.ip_item = rumps.MenuItem( + f"Tailscale IP: {self.tailscale_ip or 'Not connected'}" + ) + self.ip_item.set_callback(None) + + self.open_voice_item = rumps.MenuItem("Open Voice UI") + self.open_terminal_item = rumps.MenuItem("Open Terminal") + self.toggle_item = rumps.MenuItem("Start Services") + + log_menu = rumps.MenuItem("View Logs") + log_menu.add(rumps.MenuItem("ttyd.log")) + log_menu.add(rumps.MenuItem("voice-wrapper.log")) + + self.autostart_item = rumps.MenuItem("Auto-start on Login") + + quit_item = rumps.MenuItem("Quit") + + self.menu = [ + self.status_item, + None, + self.ip_item, + self.open_voice_item, + self.open_terminal_item, + None, + self.toggle_item, + None, + log_menu, + self.autostart_item, + None, + quit_item, + ] + + def _get_tailscale_ip(self): + try: + result = subprocess.run( + ["tailscale", "ip", "-4"], + capture_output=True, text=True, timeout=5, + ) + return result.stdout.strip() if result.returncode == 0 else None + except (subprocess.TimeoutExpired, FileNotFoundError): + return None + + @rumps.clicked("Open Voice UI") + def open_voice_ui(self, _): + if self.tailscale_ip: + subprocess.run(["open", f"http://{self.tailscale_ip}:8080"]) + + @rumps.clicked("Open Terminal") + def open_terminal(self, _): + if self.tailscale_ip: + subprocess.run(["open", f"http://{self.tailscale_ip}:7681"]) + + @rumps.clicked("Start Services") + def toggle_services(self, _): + pass # Implemented in Task 4 + + @rumps.clicked("ttyd.log") + def view_ttyd_log(self, _): + log_path = os.path.join(LOG_DIR, "ttyd.log") + if os.path.exists(log_path): + subprocess.run(["open", "-a", "Console", log_path]) + + @rumps.clicked("voice-wrapper.log") + def view_voice_log(self, _): + log_path = os.path.join(LOG_DIR, "voice-wrapper.log") + if os.path.exists(log_path): + subprocess.run(["open", "-a", "Console", log_path]) + + @rumps.clicked("Auto-start on Login") + def toggle_autostart(self, _): + pass # Implemented in Task 6 + + @rumps.clicked("Quit") + def quit_app(self, _): + rumps.quit_application() + + +if __name__ == "__main__": + RemoteCLIApp().run() From 9a532f22a38c958d299655c219136cef81b7e21d Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 00:02:59 -0500 Subject: [PATCH 02/10] feat: add health checking with 5-second polling timer Co-Authored-By: Claude Opus 4.6 --- scripts/menubar.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/scripts/menubar.py b/scripts/menubar.py index 63d7904..35b6a01 100755 --- a/scripts/menubar.py +++ b/scripts/menubar.py @@ -66,6 +66,59 @@ def _get_tailscale_ip(self): except (subprocess.TimeoutExpired, FileNotFoundError): return None + @rumps.timer(5) + def health_check(self, _): + """Poll PID files and process liveness every 5 seconds.""" + self.tailscale_ip = self._get_tailscale_ip() + self.ip_item.title = ( + f"Tailscale IP: {self.tailscale_ip or 'Not connected'}" + ) + + services = {"ttyd": False, "voice-wrapper": False, "caffeinate": False} + for name in services: + pid = self._read_pid(name) + if pid and self._is_process_alive(pid): + services[name] = True + + alive = sum(services.values()) + if alive == 3 and self.tailscale_ip: + self.title = ICON_GREEN + self.status_item.title = "Status: Running (all services healthy)" + self.toggle_item.title = "Stop Services" + elif alive == 0: + self.title = ICON_GRAY + self.status_item.title = "Status: Stopped" + self.toggle_item.title = "Start Services" + else: + self.title = ICON_RED + down = [n for n, up in services.items() if not up] + self.status_item.title = f"Status: Degraded ({', '.join(down)} down)" + self.toggle_item.title = "Stop Services" + + # Update URL menu items availability + has_ip = self.tailscale_ip is not None + self.open_voice_item.set_callback( + self.open_voice_ui if has_ip else None + ) + self.open_terminal_item.set_callback( + self.open_terminal if has_ip else None + ) + + def _read_pid(self, service_name): + pid_file = os.path.join(LOG_DIR, f"{service_name}.pid") + try: + with open(pid_file) as f: + return int(f.read().strip()) + except (FileNotFoundError, ValueError): + return None + + def _is_process_alive(self, pid): + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError): + return False + @rumps.clicked("Open Voice UI") def open_voice_ui(self, _): if self.tailscale_ip: From e05782cc223677cd007c31e0239664fb72b64341 Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 00:03:50 -0500 Subject: [PATCH 03/10] feat: implement start/stop services toggle Co-Authored-By: Claude Opus 4.6 --- scripts/menubar.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/menubar.py b/scripts/menubar.py index 35b6a01..dfd1c46 100755 --- a/scripts/menubar.py +++ b/scripts/menubar.py @@ -131,7 +131,26 @@ def open_terminal(self, _): @rumps.clicked("Start Services") def toggle_services(self, _): - pass # Implemented in Task 4 + if self.toggle_item.title == "Start Services": + self._start_services() + else: + self._stop_services() + + def _start_services(self): + start_script = os.path.join(SCRIPT_DIR, "start-remote-cli.sh") + subprocess.Popen( + [start_script], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def _stop_services(self): + stop_script = os.path.join(SCRIPT_DIR, "stop-remote-cli.sh") + subprocess.run( + [stop_script], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) @rumps.clicked("ttyd.log") def view_ttyd_log(self, _): From 30b4854c8159b3778f448d32d5b2d6baea1ef42a Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 00:04:45 -0500 Subject: [PATCH 04/10] feat: add JSON config file for preferences Co-Authored-By: Claude Opus 4.6 --- scripts/menubar.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/menubar.py b/scripts/menubar.py index dfd1c46..693f26e 100755 --- a/scripts/menubar.py +++ b/scripts/menubar.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Claude Code Remote — macOS menu bar app.""" +import json import os import signal import subprocess @@ -10,6 +11,13 @@ PROJECT_DIR = os.path.dirname(SCRIPT_DIR) LOG_DIR = os.path.join(PROJECT_DIR, "logs") +CONFIG_DIR = os.path.expanduser("~/.config/claude-code-remote") +CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") + +DEFAULT_CONFIG = { + "auto_start_services": False, +} + # Icon states ICON_GREEN = "● CC" ICON_GRAY = "○ CC" @@ -56,6 +64,22 @@ def __init__(self): quit_item, ] + self.config = self._load_config() + if self.config["auto_start_services"]: + self._start_services() + + def _load_config(self): + try: + with open(CONFIG_FILE) as f: + return {**DEFAULT_CONFIG, **json.load(f)} + except (FileNotFoundError, json.JSONDecodeError): + return dict(DEFAULT_CONFIG) + + def _save_config(self, config): + os.makedirs(CONFIG_DIR, exist_ok=True) + with open(CONFIG_FILE, "w") as f: + json.dump(config, f, indent=2) + def _get_tailscale_ip(self): try: result = subprocess.run( From d8ffa354ae395871ff191dc7b29da29a2a546476 Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 00:05:19 -0500 Subject: [PATCH 05/10] feat: implement auto-start on login toggle via launchd plist Co-Authored-By: Claude Opus 4.6 --- scripts/menubar.py | 61 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/scripts/menubar.py b/scripts/menubar.py index 693f26e..9f5d96f 100755 --- a/scripts/menubar.py +++ b/scripts/menubar.py @@ -18,6 +18,35 @@ "auto_start_services": False, } +MENUBAR_PLIST_LABEL = "com.user.claude-code-remote-menubar" +MENUBAR_PLIST_PATH = os.path.expanduser( + f"~/Library/LaunchAgents/{MENUBAR_PLIST_LABEL}.plist" +) + +MENUBAR_PLIST_TEMPLATE = """\ + + + + + Label + {label} + ProgramArguments + + {python} + {script} + + RunAtLoad + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + +""" + # Icon states ICON_GREEN = "● CC" ICON_GRAY = "○ CC" @@ -68,6 +97,8 @@ def __init__(self): if self.config["auto_start_services"]: self._start_services() + self.autostart_item.state = self._is_login_plist_installed() + def _load_config(self): try: with open(CONFIG_FILE) as f: @@ -189,8 +220,34 @@ def view_voice_log(self, _): subprocess.run(["open", "-a", "Console", log_path]) @rumps.clicked("Auto-start on Login") - def toggle_autostart(self, _): - pass # Implemented in Task 6 + def toggle_autostart(self, sender): + if sender.state: + self._uninstall_login_plist() + sender.state = False + else: + self._install_login_plist() + sender.state = True + + def _is_login_plist_installed(self): + return os.path.exists(MENUBAR_PLIST_PATH) + + def _install_login_plist(self): + import sys + plist_content = MENUBAR_PLIST_TEMPLATE.format( + label=MENUBAR_PLIST_LABEL, + python=sys.executable, + script=os.path.abspath(__file__), + ) + os.makedirs(os.path.dirname(MENUBAR_PLIST_PATH), exist_ok=True) + with open(MENUBAR_PLIST_PATH, "w") as f: + f.write(plist_content) + subprocess.run(["launchctl", "load", MENUBAR_PLIST_PATH]) + + def _uninstall_login_plist(self): + subprocess.run(["launchctl", "unload", MENUBAR_PLIST_PATH], + capture_output=True) + if os.path.exists(MENUBAR_PLIST_PATH): + os.remove(MENUBAR_PLIST_PATH) @rumps.clicked("Quit") def quit_app(self, _): From 0b5856715c866d5ad03e4d6fbd48e5682cb2a1e3 Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 00:05:29 -0500 Subject: [PATCH 06/10] feat: prompt to stop services on quit Co-Authored-By: Claude Opus 4.6 --- scripts/menubar.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/menubar.py b/scripts/menubar.py index 9f5d96f..33227b6 100755 --- a/scripts/menubar.py +++ b/scripts/menubar.py @@ -251,6 +251,15 @@ def _uninstall_login_plist(self): @rumps.clicked("Quit") def quit_app(self, _): + if self.toggle_item.title == "Stop Services": + response = rumps.alert( + title="Quit Claude Code Remote", + message="Services are still running. Stop them before quitting?", + ok="Stop & Quit", + cancel="Quit (keep running)", + ) + if response == 1: # "Stop & Quit" + self._stop_services() rumps.quit_application() From 27a5a9bcc21ba2087a321f5206e98d11714ba16b Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 00:06:41 -0500 Subject: [PATCH 07/10] docs: add menubar app to file overview and README Also remove unused signal import from menubar.py. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + README.md | 26 ++++++++++++++++++++++++++ scripts/menubar.py | 1 - 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28cf4df..6afa4ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,3 +74,4 @@ This requires customizing the plist file: | `scripts/tmux-attach.sh` | Wrapper that clears env vars and attaches to (or creates) the tmux session. | | `scripts/voice-wrapper.py` | FastAPI app serving the mobile-optimized UI with dictation support. | | `scripts/remote-cli.plist` | launchd plist for auto-start on boot. Requires `YOUR_USERNAME` replacement. | +| `scripts/menubar.py` | macOS menu bar app wrapping start/stop scripts. Provides status, URLs, logs, auto-start. | diff --git a/README.md b/README.md index 3992edf..e7c1a33 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,32 @@ To unload later: launchctl unload ~/Library/LaunchAgents/com.user.remote-cli.plist ``` +## Menu Bar App + +A macOS menu bar app gives you one-click control over the remote CLI services. + +### Install + +```bash +pip3 install rumps +``` + +### Launch + +```bash +python3 scripts/menubar.py +``` + +A "CC" icon appears in your menu bar with: + +- **Status indicator** — green (all healthy), gray (stopped), red (degraded) +- **Open Voice UI / Open Terminal** — one-click to open in browser +- **Start / Stop Services** — toggles the shell scripts +- **View Logs** — opens ttyd or voice-wrapper logs in Console.app +- **Auto-start on Login** — installs a launchd agent so the menu bar app launches at login + +The app polls service health every 5 seconds and updates the icon automatically. + ## Usage Tips | Action | How | diff --git a/scripts/menubar.py b/scripts/menubar.py index 33227b6..09a1cda 100755 --- a/scripts/menubar.py +++ b/scripts/menubar.py @@ -3,7 +3,6 @@ import json import os -import signal import subprocess import rumps From 789d37060a6c49c870e635f55b5e192cc9f6831d Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 09:13:49 -0500 Subject: [PATCH 08/10] feat: show MagicDNS name in menubar and fix PATH for launchd Add Tailscale MagicDNS hostname to the menu bar app so users can see their .ts.net domain alongside the IP. Also fix start-remote-cli.sh to prepend Homebrew Python paths so voice-wrapper launches correctly when started from launchd or the menubar app. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- README.md | 6 ++++++ scripts/menubar.py | 25 +++++++++++++++++++++++++ scripts/start-remote-cli.sh | 6 ++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6afa4ed..d23fb9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,4 +74,4 @@ This requires customizing the plist file: | `scripts/tmux-attach.sh` | Wrapper that clears env vars and attaches to (or creates) the tmux session. | | `scripts/voice-wrapper.py` | FastAPI app serving the mobile-optimized UI with dictation support. | | `scripts/remote-cli.plist` | launchd plist for auto-start on boot. Requires `YOUR_USERNAME` replacement. | -| `scripts/menubar.py` | macOS menu bar app wrapping start/stop scripts. Provides status, URLs, logs, auto-start. | +| `scripts/menubar.py` | macOS menu bar app wrapping start/stop scripts. Provides status, URLs, logs, auto-start. Launch in background with `nohup python3 scripts/menubar.py &>/dev/null &`. | diff --git a/README.md b/README.md index e7c1a33..6fba1ff 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,12 @@ pip3 install rumps python3 scripts/menubar.py ``` +To run it in the background: + +```bash +nohup python3 scripts/menubar.py &>/dev/null & +``` + A "CC" icon appears in your menu bar with: - **Status indicator** — green (all healthy), gray (stopped), red (degraded) diff --git a/scripts/menubar.py b/scripts/menubar.py index 09a1cda..1df2c3f 100755 --- a/scripts/menubar.py +++ b/scripts/menubar.py @@ -56,6 +56,7 @@ class RemoteCLIApp(rumps.App): def __init__(self): super().__init__(ICON_GRAY, quit_button=None) self.tailscale_ip = self._get_tailscale_ip() + self.tailscale_dns = self._get_tailscale_dns() self.status_item = rumps.MenuItem("Status: Stopped") self.status_item.set_callback(None) @@ -65,6 +66,11 @@ def __init__(self): ) self.ip_item.set_callback(None) + self.dns_item = rumps.MenuItem( + f"MagicDNS: {self.tailscale_dns or 'Not available'}" + ) + self.dns_item.set_callback(None) + self.open_voice_item = rumps.MenuItem("Open Voice UI") self.open_terminal_item = rumps.MenuItem("Open Terminal") self.toggle_item = rumps.MenuItem("Start Services") @@ -81,6 +87,7 @@ def __init__(self): self.status_item, None, self.ip_item, + self.dns_item, self.open_voice_item, self.open_terminal_item, None, @@ -120,13 +127,31 @@ def _get_tailscale_ip(self): except (subprocess.TimeoutExpired, FileNotFoundError): return None + def _get_tailscale_dns(self): + try: + result = subprocess.run( + ["tailscale", "status", "--json"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + data = json.loads(result.stdout) + dns_name = data.get("Self", {}).get("DNSName", "") + return dns_name.rstrip(".") if dns_name else None + except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError): + pass + return None + @rumps.timer(5) def health_check(self, _): """Poll PID files and process liveness every 5 seconds.""" self.tailscale_ip = self._get_tailscale_ip() + self.tailscale_dns = self._get_tailscale_dns() self.ip_item.title = ( f"Tailscale IP: {self.tailscale_ip or 'Not connected'}" ) + self.dns_item.title = ( + f"MagicDNS: {self.tailscale_dns or 'Not available'}" + ) services = {"ttyd": False, "voice-wrapper": False, "caffeinate": False} for name in services: diff --git a/scripts/start-remote-cli.sh b/scripts/start-remote-cli.sh index 4f43549..46b0826 100755 --- a/scripts/start-remote-cli.sh +++ b/scripts/start-remote-cli.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash set -euo pipefail +# Ensure Homebrew paths are available (needed when launched from launchd/menubar) +for p in /opt/homebrew/opt/python@3.11/libexec/bin /opt/homebrew/bin /usr/local/bin; do + [[ ":$PATH:" != *":$p:"* ]] && [ -d "$p" ] && PATH="$p:$PATH" +done +export PATH + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" LOG_DIR="$PROJECT_DIR/logs" From 5551d66fc67269b47bec3430a2ec414d80de3420 Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 14:34:38 -0500 Subject: [PATCH 09/10] fix: don't call launchctl load/unload when toggling auto-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling launchctl load immediately starts a second instance of the menubar app, and launchctl unload kills the running one. Just write/remove the plist file instead — it takes effect on next login. Co-Authored-By: Claude Opus 4.6 --- scripts/menubar.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/menubar.py b/scripts/menubar.py index 1df2c3f..ab32864 100755 --- a/scripts/menubar.py +++ b/scripts/menubar.py @@ -265,11 +265,8 @@ def _install_login_plist(self): os.makedirs(os.path.dirname(MENUBAR_PLIST_PATH), exist_ok=True) with open(MENUBAR_PLIST_PATH, "w") as f: f.write(plist_content) - subprocess.run(["launchctl", "load", MENUBAR_PLIST_PATH]) def _uninstall_login_plist(self): - subprocess.run(["launchctl", "unload", MENUBAR_PLIST_PATH], - capture_output=True) if os.path.exists(MENUBAR_PLIST_PATH): os.remove(MENUBAR_PLIST_PATH) From 09d8c81d5ba0e0c4e5df12c29a8a3dcaa5249904 Mon Sep 17 00:00:00 2001 From: Gian Luca D'Intino-Conte Date: Thu, 19 Feb 2026 14:39:25 -0500 Subject: [PATCH 10/10] feat: use MagicDNS name for Open Voice UI and Open Terminal URLs Falls back to Tailscale IP if MagicDNS is not available. Co-Authored-By: Claude Opus 4.6 --- scripts/menubar.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/menubar.py b/scripts/menubar.py index ab32864..9b170b6 100755 --- a/scripts/menubar.py +++ b/scripts/menubar.py @@ -200,13 +200,15 @@ def _is_process_alive(self, pid): @rumps.clicked("Open Voice UI") def open_voice_ui(self, _): - if self.tailscale_ip: - subprocess.run(["open", f"http://{self.tailscale_ip}:8080"]) + host = self.tailscale_dns or self.tailscale_ip + if host: + subprocess.run(["open", f"http://{host}:8080"]) @rumps.clicked("Open Terminal") def open_terminal(self, _): - if self.tailscale_ip: - subprocess.run(["open", f"http://{self.tailscale_ip}:7681"]) + host = self.tailscale_dns or self.tailscale_ip + if host: + subprocess.run(["open", f"http://{host}:7681"]) @rumps.clicked("Start Services") def toggle_services(self, _):