Skip to content
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 &`. |
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
290 changes: 290 additions & 0 deletions scripts/menubar.py
Original file line number Diff line number Diff line change
@@ -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 = """\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" \
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{python}</string>
<string>{script}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
"""

# 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()
6 changes: 6 additions & 0 deletions scripts/start-remote-cli.sh
Original file line number Diff line number Diff line change
@@ -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"
Expand Down