#!/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()