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>
This commit is contained in:
421
install_wizard.py
Normal file
421
install_wizard.py
Normal file
@@ -0,0 +1,421 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user