#!/usr/bin/env python3 """odysseus-theme — shell wrapper for the theme system. Built-in preset definitions live in `static/js/theme.js` (the `THEMES` export). Each user's chosen preset + custom-color overrides persist in `data/user_prefs.json` under that user's key, so we can read/write themes from the shell without needing the browser. odysseus-theme list # built-in preset names odysseus-theme users # users with a saved theme odysseus-theme get --user alice@example.com # one user's theme blob odysseus-theme set --user alice@example.com --preset chatgpt odysseus-theme export --user alice@example.com --pretty > theme.json `set --preset NAME` clears any custom-color overrides — pass `--keep-colors` to preserve them (useful when switching presets but keeping your manual tweaks). """ from __future__ import annotations import sys import os, sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib")) from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT quiet_logs() import argparse, json, logging, os, shutil, subprocess, sys, tempfile from pathlib import Path _USER_PREFS_PATH = _REPO_ROOT / "data" / "user_prefs.json" _THEME_JS = _REPO_ROOT / "static" / "js" / "theme.js" def _load_prefs() -> dict: if not _USER_PREFS_PATH.exists(): return {"_users": {}} try: data = json.loads(_USER_PREFS_PATH.read_text()) except json.JSONDecodeError as e: fail(f"user_prefs.json is corrupt: {e}") if not isinstance(data, dict): fail("user_prefs.json is corrupt: expected an object") users = data.setdefault("_users", {}) if not isinstance(users, dict): fail("user_prefs.json is corrupt: _users must be an object") return data def _save_prefs(data: dict) -> None: _USER_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True) # Backup so a bad write doesn't nuke everyone's theme. if _USER_PREFS_PATH.exists(): try: (_USER_PREFS_PATH.with_suffix(_USER_PREFS_PATH.suffix + ".bak")).write_bytes( _USER_PREFS_PATH.read_bytes() ) except Exception: pass tmp = _USER_PREFS_PATH.with_suffix(_USER_PREFS_PATH.suffix + ".tmp") tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False)) tmp.replace(_USER_PREFS_PATH) def _builtin_preset_names() -> list[str]: """Read the THEMES export from theme.js. Uses node so we don't have to write a JS parser. Falls back to a regex scrape if node isn't available.""" if not _THEME_JS.exists(): return [] if shutil.which("node"): script = ( f"const m = await import('{_THEME_JS.as_posix()}'); " "console.log(JSON.stringify(Object.keys(m.THEMES || {})));" ) try: out = subprocess.run( ["node", "--input-type=module", "-e", script], capture_output=True, text=True, timeout=10, ) if out.returncode == 0: lines = [l for l in out.stdout.splitlines() if l.strip()] if lines: return json.loads(lines[-1]) except Exception: pass # Fallback: regex-scrape from the file. Less precise than node, # but works without a JS runtime. We only match keys at depth 1 # (exactly 2 leading spaces) so nested `advanced: { userBubbleBg: ... }` # entries don't leak into the preset list. import re src = _THEME_JS.read_text() m = re.search(r"export\s+const\s+THEMES\s*=\s*\{(.*?)\n\};", src, re.DOTALL) if not m: return [] body = m.group(1) return re.findall(r"^ ([a-zA-Z_][\w]*)\s*:", body, re.MULTILINE) # ─── list ──────────────────────────────────────────────────────────── def cmd_list(args): names = _builtin_preset_names() emit({"presets": names, "count": len(names)}, args) # ─── users ─────────────────────────────────────────────────────────── def cmd_users(args): users = _load_prefs().get("_users", {}) out = [] for username, prefs in users.items(): theme = (prefs or {}).get("theme") or {} out.append({ "user": username, "preset": theme.get("name") or "", "has_custom_colors": bool(theme.get("colors")), "font": theme.get("font") or "", "density": theme.get("density") or "", }) out.sort(key=lambda r: r["user"]) emit(out, args) # ─── get ───────────────────────────────────────────────────────────── def cmd_get(args): users = _load_prefs().get("_users", {}) prefs = users.get(args.user) if prefs is None: fail(f"no prefs for user {args.user!r}") theme = (prefs or {}).get("theme") or {} if not theme: emit({"user": args.user, "theme": None}, args) return emit({"user": args.user, "theme": theme}, args) # ─── set ───────────────────────────────────────────────────────────── def cmd_set(args): valid = _builtin_preset_names() if valid and args.preset not in valid: fail( f"unknown preset {args.preset!r}. Available: {', '.join(valid) or '(none discovered)'}" ) data = _load_prefs() users = data.setdefault("_users", {}) user_prefs = users.setdefault(args.user, {}) theme = dict(user_prefs.get("theme") or {}) theme["name"] = args.preset if not args.keep_colors: theme.pop("colors", None) user_prefs["theme"] = theme _save_prefs(data) emit({"ok": True, "user": args.user, "preset": args.preset, "kept_colors": args.keep_colors}, args) # ─── export ────────────────────────────────────────────────────────── def cmd_export(args): users = _load_prefs().get("_users", {}) prefs = users.get(args.user) if prefs is None: fail(f"no prefs for user {args.user!r}") emit(prefs.get("theme") or {}, args) def _build_parser(): common = argparse.ArgumentParser(add_help=False) common.add_argument("--pretty", action="store_true") p = argparse.ArgumentParser(prog="odysseus-theme", parents=[common]) sub = p.add_subparsers(dest="cmd", required=True) pl = sub.add_parser("list", parents=[common]) pl.set_defaults(func=cmd_list) pu = sub.add_parser("users", parents=[common]) pu.set_defaults(func=cmd_users) pg = sub.add_parser("get", parents=[common]) pg.add_argument("--user", required=True) pg.set_defaults(func=cmd_get) ps = sub.add_parser("set", parents=[common]) ps.add_argument("--user", required=True) ps.add_argument("--preset", required=True) ps.add_argument("--keep-colors", action="store_true", help="don't clear custom color overrides") ps.set_defaults(func=cmd_set) pe = sub.add_parser("export", parents=[common]) pe.add_argument("--user", required=True) pe.set_defaults(func=cmd_export) return p if __name__ == "__main__": sys.exit(run(_build_parser()))