Files
claude-statusline/install_wizard.py
Axel Meyer 6a1b4bd022 Add desktop tray widget + installer wizard
- Desktop widget (Python/pystray): system tray icon showing 5h usage as
  circular progress bar with Claude starburst logo, 10-step green-to-red
  color scale, right-click menu with usage stats and configuration
- Shared cache: both widget and CLI statusline read/write the same
  /tmp/claude_usage.json — only one fetcher needs to run
- Installer wizard (install_wizard.py): interactive cross-platform setup
  with component selection, session key prompt, cron/autostart config
- OS wrappers: install.sh (Linux/macOS) and install.ps1 (Windows) find
  Python 3.9+ and launch the wizard
- README with topology diagram, usage docs, and configuration reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:54:32 +00:00

422 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Cross-platform installer wizard for claude-statusline.
Installs one or both components:
- CLI Statusline + Fetcher (Node.js) — for headless servers / Claude Code
- Desktop Widget (Python) — system tray usage monitor
Uses only Python stdlib — no third-party deps required to run the installer itself.
"""
import json
import os
import platform
import shutil
import stat
import subprocess
import sys
import textwrap
# -- Paths ------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
IS_WINDOWS = sys.platform == "win32"
if IS_WINDOWS:
INSTALL_DIR = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "claude-statusline")
CONFIG_DIR = INSTALL_DIR
CLAUDE_DIR = os.path.join(os.path.expanduser("~"), ".claude")
else:
INSTALL_DIR = os.path.join(os.path.expanduser("~"), ".local", "share", "claude-statusline")
CONFIG_DIR = os.path.join(
os.environ.get("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
"claude-statusline",
)
CLAUDE_DIR = os.path.join(os.path.expanduser("~"), ".claude")
SESSION_KEY_PATH = os.path.join(CONFIG_DIR, "session-key")
SETTINGS_PATH = os.path.join(CLAUDE_DIR, "settings.json")
# -- Helpers -----------------------------------------------------------------
def header(text):
width = 60
print()
print("=" * width)
print(f" {text}")
print("=" * width)
print()
def step(text):
print(f" -> {text}")
def ask_yes_no(prompt, default=True):
suffix = " [Y/n] " if default else " [y/N] "
while True:
answer = input(prompt + suffix).strip().lower()
if answer == "":
return default
if answer in ("y", "yes"):
return True
if answer in ("n", "no"):
return False
def ask_choice(prompt, options):
"""Ask user to pick from numbered options. Returns 0-based index."""
print(prompt)
for i, opt in enumerate(options, 1):
print(f" {i}) {opt}")
while True:
try:
choice = int(input(" Choice: ").strip())
if 1 <= choice <= len(options):
return choice - 1
except (ValueError, EOFError):
pass
print(f" Please enter a number between 1 and {len(options)}.")
def ask_input(prompt, default=""):
suffix = f" [{default}]" if default else ""
answer = input(f"{prompt}{suffix}: ").strip()
return answer if answer else default
def find_executable(name):
return shutil.which(name)
def run(cmd, check=True, capture=False):
kwargs = {"check": check}
if capture:
kwargs["stdout"] = subprocess.PIPE
kwargs["stderr"] = subprocess.PIPE
return subprocess.run(cmd, **kwargs)
# -- Component checks -------------------------------------------------------
def check_node():
node = find_executable("node")
if not node:
return None, "Node.js not found"
try:
result = run([node, "-e", "process.stdout.write(String(process.versions.node.split('.')[0]))"],
capture=True)
major = int(result.stdout.decode().strip())
if major < 18:
return None, f"Node.js v{major} found, but 18+ is required"
return node, f"Node.js v{major}"
except Exception as e:
return None, f"Node.js check failed: {e}"
def check_python():
"""Check if Python 3.9+ is available (we're running in it, so yes)."""
v = sys.version_info
if v >= (3, 9):
return sys.executable, f"Python {v.major}.{v.minor}.{v.micro}"
return None, f"Python {v.major}.{v.minor} found, but 3.9+ is required"
# -- Installers --------------------------------------------------------------
def install_statusline(node_bin):
"""Install CLI statusline + fetcher (Node.js)."""
header("Installing CLI Statusline + Fetcher")
os.makedirs(INSTALL_DIR, exist_ok=True)
# Copy scripts
for fname in ("statusline.js", "fetch-usage.js"):
src = os.path.join(SCRIPT_DIR, fname)
dst = os.path.join(INSTALL_DIR, fname)
shutil.copy2(src, dst)
if not IS_WINDOWS:
os.chmod(dst, os.stat(dst).st_mode | stat.S_IXUSR)
step(f"Scripts copied to {INSTALL_DIR}")
# Configure Claude Code statusline
os.makedirs(CLAUDE_DIR, exist_ok=True)
statusline_cmd = f"{node_bin} --no-warnings {os.path.join(INSTALL_DIR, 'statusline.js')}"
if os.path.exists(SETTINGS_PATH):
with open(SETTINGS_PATH, "r") as f:
try:
settings = json.load(f)
except json.JSONDecodeError:
settings = {}
if "statusLine" in settings:
step("statusLine already configured in settings.json — skipping")
else:
settings["statusLine"] = {"type": "command", "command": statusline_cmd}
with open(SETTINGS_PATH, "w") as f:
json.dump(settings, f, indent=2)
f.write("\n")
step(f"Added statusLine to {SETTINGS_PATH}")
else:
settings = {"statusLine": {"type": "command", "command": statusline_cmd}}
with open(SETTINGS_PATH, "w") as f:
json.dump(settings, f, indent=2)
f.write("\n")
step(f"Created {SETTINGS_PATH}")
# Cron setup
if not IS_WINDOWS:
cron_line = f"*/5 * * * * {node_bin} {os.path.join(INSTALL_DIR, 'fetch-usage.js')} 2>/dev/null"
if ask_yes_no(" Set up cron job for usage fetcher (every 5 min)?"):
try:
result = run(["crontab", "-l"], capture=True, check=False)
existing = result.stdout.decode() if result.returncode == 0 else ""
if "fetch-usage.js" in existing:
step("Cron job already exists — skipping")
else:
new_crontab = existing.rstrip("\n") + "\n" + cron_line + "\n"
proc = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE)
proc.communicate(input=new_crontab.encode())
step("Cron job added")
except Exception as e:
step(f"Could not set up cron: {e}")
print(f" Manual setup: crontab -e, then add:")
print(f" {cron_line}")
else:
print(f"\n To set up manually later:")
print(f" crontab -e")
print(f" {cron_line}")
step("CLI Statusline installation complete")
def install_widget(python_bin):
"""Install desktop tray widget (Python)."""
header("Installing Desktop Widget")
if not IS_WINDOWS:
# Install system deps for AppIndicator (Linux)
step("Checking system dependencies...")
deps = ["python3-gi", "gir1.2-ayatanaappindicator3-0.1", "python3-venv", "python3-tk"]
if ask_yes_no(f" Install system packages ({', '.join(deps)})? (requires sudo)"):
try:
run(["sudo", "apt-get", "update", "-qq"])
run(["sudo", "apt-get", "install", "-y", "-qq"] + deps)
step("System packages installed")
except Exception as e:
step(f"Warning: Could not install system packages: {e}")
print(" You may need to install them manually.")
os.makedirs(INSTALL_DIR, exist_ok=True)
if IS_WINDOWS:
# Install directly into user Python
step("Installing Python packages...")
run([python_bin, "-m", "pip", "install", "--quiet", "--upgrade", "pystray", "Pillow"])
# Copy source
dst = os.path.join(INSTALL_DIR, "claude_usage_widget")
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(os.path.join(SCRIPT_DIR, "claude_usage_widget"), dst)
step(f"Widget source copied to {INSTALL_DIR}")
# Startup shortcut
if ask_yes_no(" Create Windows Startup shortcut (autostart on login)?"):
_create_windows_shortcut(python_bin)
else:
# Create venv with system-site-packages for gi access
venv_dir = os.path.join(INSTALL_DIR, "venv")
step(f"Creating venv at {venv_dir}...")
run([python_bin, "-m", "venv", "--system-site-packages", venv_dir])
venv_python = os.path.join(venv_dir, "bin", "python")
venv_pip = os.path.join(venv_dir, "bin", "pip")
step("Installing Python packages into venv...")
run([venv_pip, "install", "--quiet", "--upgrade", "pip"])
run([venv_pip, "install", "--quiet", "pystray", "Pillow"])
# Copy source
dst = os.path.join(INSTALL_DIR, "claude_usage_widget")
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(os.path.join(SCRIPT_DIR, "claude_usage_widget"), dst)
step(f"Widget source copied to {INSTALL_DIR}")
# Autostart .desktop file
if ask_yes_no(" Create autostart entry (launch widget on login)?"):
autostart_dir = os.path.join(os.path.expanduser("~"), ".config", "autostart")
os.makedirs(autostart_dir, exist_ok=True)
desktop_path = os.path.join(autostart_dir, "claude-usage-widget.desktop")
with open(desktop_path, "w") as f:
f.write(textwrap.dedent(f"""\
[Desktop Entry]
Type=Application
Name=Claude Usage Widget
Comment=System tray widget showing Claude API usage
Exec={venv_python} -m claude_usage_widget
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
StartupNotify=false
Terminal=false
"""))
step(f"Autostart entry created at {desktop_path}")
python_bin = venv_python # for the launch hint
step("Desktop Widget installation complete")
# Print launch command
if IS_WINDOWS:
pythonw = find_executable("pythonw") or python_bin
print(f"\n Launch now: {pythonw} -m claude_usage_widget")
print(f" (from {INSTALL_DIR})")
else:
print(f"\n Launch now: {python_bin} -m claude_usage_widget")
def _create_windows_shortcut(python_bin):
"""Create a .lnk shortcut in Windows Startup folder."""
try:
startup_dir = os.path.join(
os.environ["APPDATA"], "Microsoft", "Windows", "Start Menu", "Programs", "Startup"
)
pythonw = find_executable("pythonw")
if not pythonw:
pythonw = python_bin.replace("python.exe", "pythonw.exe")
# Use PowerShell to create .lnk (no COM dependency)
lnk_path = os.path.join(startup_dir, "Claude Usage Widget.lnk")
ps_script = textwrap.dedent(f"""\
$ws = New-Object -ComObject WScript.Shell
$s = $ws.CreateShortcut("{lnk_path}")
$s.TargetPath = "{pythonw}"
$s.Arguments = "-m claude_usage_widget"
$s.WorkingDirectory = "{INSTALL_DIR}"
$s.Description = "Claude Usage Widget"
$s.Save()
""")
run(["powershell", "-Command", ps_script])
step(f"Startup shortcut created at {lnk_path}")
except Exception as e:
step(f"Warning: Could not create shortcut: {e}")
# -- Session key setup -------------------------------------------------------
def setup_session_key():
"""Prompt user for session key if not already set."""
os.makedirs(CONFIG_DIR, exist_ok=True)
existing = ""
try:
with open(SESSION_KEY_PATH, "r") as f:
existing = f.read().strip()
except FileNotFoundError:
pass
if existing and not existing.startswith("#"):
step(f"Session key already configured ({len(existing)} chars)")
if not ask_yes_no(" Update session key?", default=False):
return
print()
print(" To get your session key:")
print(" 1. Log into https://claude.ai in any browser")
print(" 2. Open DevTools -> Application -> Cookies -> claude.ai")
print(" 3. Copy the value of the 'sessionKey' cookie")
print()
key = ask_input(" Paste session key (or leave blank to skip)")
if key:
with open(SESSION_KEY_PATH, "w") as f:
f.write(key + "\n")
if not IS_WINDOWS:
os.chmod(SESSION_KEY_PATH, 0o600)
step("Session key saved")
else:
if not existing or existing.startswith("#"):
with open(SESSION_KEY_PATH, "w") as f:
f.write("# Paste your claude.ai sessionKey cookie value here\n")
if not IS_WINDOWS:
os.chmod(SESSION_KEY_PATH, 0o600)
step(f"Skipped — edit {SESSION_KEY_PATH} later")
# -- Main wizard -------------------------------------------------------------
def main():
header("claude-statusline installer")
print(" This wizard installs Claude usage monitoring components.")
print(f" OS: {platform.system()} {platform.release()}")
print(f" Install dir: {INSTALL_DIR}")
print(f" Config dir: {CONFIG_DIR}")
print()
# Check available runtimes
node_bin, node_status = check_node()
python_bin, python_status = check_python()
print(f" Node.js: {node_status}")
print(f" Python: {python_status}")
print()
# Component selection
options = []
if node_bin and python_bin:
choice = ask_choice("What would you like to install?", [
"CLI Statusline + Fetcher (for Claude Code / headless servers)",
"Desktop Widget (system tray usage monitor)",
"Both",
])
install_cli = choice in (0, 2)
install_wid = choice in (1, 2)
elif node_bin:
print(" Only Node.js available — CLI Statusline + Fetcher will be installed.")
print(" (Install Python 3.9+ to also use the Desktop Widget.)")
install_cli = True
install_wid = False
elif python_bin:
print(" Only Python available — Desktop Widget will be installed.")
print(" (Install Node.js 18+ to also use the CLI Statusline.)")
install_cli = False
install_wid = True
else:
print(" ERROR: Neither Node.js 18+ nor Python 3.9+ found.")
print(" Install at least one runtime and try again.")
sys.exit(1)
# Session key
header("Session Key Setup")
setup_session_key()
# Install selected components
if install_cli:
install_statusline(node_bin)
if install_wid:
install_widget(python_bin)
# Done
header("Installation Complete")
if install_cli:
print(" CLI Statusline: Restart Claude Code to see the status bar.")
if install_wid:
print(" Desktop Widget: Launch or log out/in to start via autostart.")
print()
print(" Session key: " + SESSION_KEY_PATH)
print()
if __name__ == "__main__":
main()