diff --git a/CLAUDE.md b/CLAUDE.md index 28cf4df..d23fb9e 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. Launch in background with `nohup python3 scripts/menubar.py &>/dev/null &`. | diff --git a/README.md b/README.md index 3992edf..6fba1ff 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,38 @@ 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 +``` + +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) +- **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 new file mode 100755 index 0000000..9b170b6 --- /dev/null +++ b/scripts/menubar.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Claude Code Remote — macOS menu bar app.""" + +import json +import os +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") + +CONFIG_DIR = os.path.expanduser("~/.config/claude-code-remote") +CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") + +DEFAULT_CONFIG = { + "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" +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.tailscale_dns = self._get_tailscale_dns() + + 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.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") + + 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.dns_item, + self.open_voice_item, + self.open_terminal_item, + None, + self.toggle_item, + None, + log_menu, + self.autostart_item, + None, + quit_item, + ] + + self.config = self._load_config() + 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: + 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( + ["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 + + 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: + 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, _): + 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, _): + 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, _): + 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, _): + 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, 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) + + def _uninstall_login_plist(self): + if os.path.exists(MENUBAR_PLIST_PATH): + os.remove(MENUBAR_PLIST_PATH) + + @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() + + +if __name__ == "__main__": + RemoteCLIApp().run() 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"