| |
| """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) |
| |
| 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 |
| |
| |
| |
| |
| 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) |
|
|
|
|
| |
|
|
| def cmd_list(args): |
| names = _builtin_preset_names() |
| emit({"presets": names, "count": len(names)}, args) |
|
|
|
|
| |
|
|
| 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) |
|
|
|
|
| |
|
|
| 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) |
|
|
|
|
| |
|
|
| 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) |
|
|
|
|
| |
|
|
| 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())) |
|
|