From 3a8869b0baa737a6509460d9f38eee9f0040aa46 Mon Sep 17 00:00:00 2001 From: MeghdadFadaee Date: Sat, 13 Jun 2026 16:36:00 +0330 Subject: [PATCH] init --- .gitignore | 3 + README.md | 159 +++++++++++++++++++ bin/codex-waybar | 311 +++++++++++++++++++++++++++++++++++++ bin/codex-waybar-configure | 124 +++++++++++++++ install.sh | 69 ++++++++ tests/test_codex_waybar.py | 169 ++++++++++++++++++++ 6 files changed, 835 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/codex-waybar create mode 100755 bin/codex-waybar-configure create mode 100755 install.sh create mode 100644 tests/test_codex_waybar.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa6cef2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fff66f --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# Codex Usage Monitor for Waybar + +A Waybar custom module that displays your Codex 5-hour and weekly rate-limit usage, plus the 5-hour reset countdown. + +Default display: + +```text +5H 22% · W 18% · 2h55m +``` + +It reads usage through the installed Codex CLI's `account/rateLimits/read` app-server method. It does not read or transmit raw authentication tokens. + +## Requirements + +- Codex CLI logged in with ChatGPT +- Python 3.11 or newer +- Waybar with custom module support + +## Install + +Install the commands without changing Waybar: + +```bash +./install.sh +``` + +Install and configure the module in a selected Waybar section: + +```bash +./install.sh --configure-waybar --section right --restart +``` + +The configurator backs up `config.jsonc` and `style.css`, preserves JSONC comments, avoids duplicate module entries, and aborts if it cannot identify the requested module section safely. + +## Manual Waybar Setup + +Add `custom/codex-usage` to any one of `modules-left`, `modules-center`, or `modules-right`. + +Add this module definition to `~/.config/waybar/config.jsonc`: + +```jsonc +"custom/codex-usage": { + "exec": "codex-waybar", + "return-type": "json", + "interval": 60, + "tooltip": true +} +``` + +Then restart Waybar: + +```bash +omarchy restart waybar +``` + +## Formatting + +Formatting is configured in Waybar's `exec` command, like other custom module command options: + +```jsonc +"custom/codex-usage": { + "exec": "codex-waybar --format '5H {five_hour_used}% | W {weekly_used}% | {five_hour_reset_in}'", + "return-type": "json", + "interval": 60 +} +``` + +Available placeholders: + +| Placeholder | Meaning | +| --- | --- | +| `{five_hour_used}` | 5-hour usage percentage | +| `{weekly_used}` | Weekly usage percentage | +| `{five_hour_reset_in}` | Time remaining until the 5-hour reset | +| `{weekly_reset_in}` | Time remaining until the weekly reset | +| `{five_hour_reset_at}` | Local 5-hour reset date and time | +| `{weekly_reset_at}` | Local weekly reset date and time | +| `{plan}` | ChatGPT plan reported by Codex | + +Other command options: + +```text +--tooltip-format TEMPLATE +--warning PERCENT default: 70 +--critical PERCENT default: 90 +--timeout SECONDS default: 10 +--no-cache +``` + +## Styling + +The command emits these Waybar CSS classes: + +- `normal` +- `warning` +- `critical` +- `stale`: the live refresh failed and cached data is displayed +- `unavailable`: no live or cached data is available + +Example: + +```css +#custom-codex-usage { + margin: 0 7.5px; +} + +#custom-codex-usage.warning { + color: #d8a657; +} + +#custom-codex-usage.critical, +#custom-codex-usage.unavailable { + color: #a55555; +} + +#custom-codex-usage.stale { + opacity: 0.65; +} +``` + +## Troubleshooting + +Run the module directly. It always prints Waybar-compatible JSON: + +```bash +codex-waybar | jq +``` + +If it reports unavailable usage, confirm that `codex` is installed and logged in: + +```bash +codex login status +``` + +The last valid response is cached at `${XDG_CACHE_HOME:-~/.cache}/codex-waybar/rate-limits.json`. The cache contains rate-limit percentages and reset timestamps, not credentials. + +## Development + +Run the tests: + +```bash +python -m unittest discover -s tests -v +``` + +Run directly from the repository: + +```bash +./bin/codex-waybar | jq +``` + +## Uninstall + +Remove the installed commands: + +```bash +rm ~/.local/bin/codex-waybar ~/.local/bin/codex-waybar-configure +``` + +Remove `custom/codex-usage` from your Waybar module list and module definition, then remove its CSS rules. diff --git a/bin/codex-waybar b/bin/codex-waybar new file mode 100755 index 0000000..c6a219e --- /dev/null +++ b/bin/codex-waybar @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Waybar custom module for Codex account rate limits.""" + +from __future__ import annotations + +import argparse +import html +import json +import os +import selectors +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path +from string import Formatter +from typing import Any + +FIVE_HOUR_MINS = 300 +WEEKLY_MINS = 10_080 +DEFAULT_FORMAT = "5H {five_hour_used}% · W {weekly_used}% · {five_hour_reset_in}" +DEFAULT_TOOLTIP = ( + "Codex ({plan})\n" + "5-hour: {five_hour_used}% used, resets {five_hour_reset_at} ({five_hour_reset_in})\n" + "Weekly: {weekly_used}% used, resets {weekly_reset_at} ({weekly_reset_in})" +) +ALLOWED_FIELDS = { + "five_hour_used", + "weekly_used", + "five_hour_reset_in", + "weekly_reset_in", + "five_hour_reset_at", + "weekly_reset_at", + "plan", +} + + +class UsageError(RuntimeError): + pass + + +def cache_path() -> Path: + root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) + return root / "codex-waybar" / "rate-limits.json" + + +def parse_json_lines(output: str, response_id: int = 2) -> dict[str, Any]: + for line in output.splitlines(): + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if message.get("id") == response_id: + if "error" in message: + raise UsageError(str(message["error"])) + result = message.get("result") + if isinstance(result, dict): + return result + raise UsageError("Codex returned an invalid rate-limit result") + raise UsageError("Codex did not return rate-limit data") + + +def fetch_rate_limits(timeout: float, codex_command: str = "codex") -> dict[str, Any]: + requests = [ + { + "id": 1, + "method": "initialize", + "params": { + "clientInfo": {"name": "codex-waybar", "version": "1.0.0"}, + }, + }, + {"method": "initialized"}, + {"id": 2, "method": "account/rateLimits/read"}, + ] + payload = "\n".join(json.dumps(item, separators=(",", ":")) for item in requests) + "\n" + process = None + try: + process = subprocess.Popen( + [codex_command, "app-server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + assert process.stdin is not None + assert process.stdout is not None + process.stdin.write(payload) + process.stdin.flush() + + deadline = time.monotonic() + timeout + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ) + while time.monotonic() < deadline: + events = selector.select(deadline - time.monotonic()) + if not events: + break + line = process.stdout.readline() + if not line: + break + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if message.get("id") == 2: + return parse_json_lines(line) + raise UsageError("Codex app-server timed out or returned no rate-limit data") + except FileNotFoundError as exc: + raise UsageError(f"{codex_command!r} was not found") from exc + finally: + if process is not None: + if process.stdin: + try: + process.stdin.close() + except OSError: + pass + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def select_snapshot(result: dict[str, Any]) -> dict[str, Any]: + by_id = result.get("rateLimitsByLimitId") + if isinstance(by_id, dict): + codex = by_id.get("codex") + if isinstance(codex, dict): + return codex + for value in by_id.values(): + if isinstance(value, dict): + return value + snapshot = result.get("rateLimits") + if isinstance(snapshot, dict): + return snapshot + raise UsageError("No Codex rate-limit snapshot is available") + + +def select_windows(snapshot: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + windows = [ + window + for key in ("primary", "secondary") + if isinstance((window := snapshot.get(key)), dict) + ] + by_duration = {window.get("windowDurationMins"): window for window in windows} + five_hour = by_duration.get(FIVE_HOUR_MINS) + weekly = by_duration.get(WEEKLY_MINS) + if not isinstance(five_hour, dict) or not isinstance(weekly, dict): + raise UsageError("The 5-hour or weekly Codex limit window is unavailable") + return five_hour, weekly + + +def load_cache(path: Path) -> dict[str, Any] | None: + try: + value = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def save_cache(path: Path, result: dict[str, Any]) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps({"saved_at": int(time.time()), "result": result})) + temporary.replace(path) + except OSError: + pass + + +def human_countdown(resets_at: Any, now: int) -> str: + if not isinstance(resets_at, int): + return "unknown" + remaining = max(0, resets_at - now) + days, remainder = divmod(remaining, 86_400) + hours, remainder = divmod(remainder, 3_600) + minutes = (remainder + 59) // 60 + if minutes == 60: + hours += 1 + minutes = 0 + if hours == 24: + days += 1 + hours = 0 + if days: + return f"{days}d {hours}h" + if hours: + return f"{hours}h {minutes:02d}m" + return f"{minutes}m" + + +def reset_time(resets_at: Any) -> str: + if not isinstance(resets_at, int): + return "unknown" + return datetime.fromtimestamp(resets_at).astimezone().strftime("%Y-%m-%d %H:%M") + + +def validate_template(template: str) -> None: + try: + fields = {field for _, field, _, _ in Formatter().parse(template) if field} + except ValueError as exc: + raise UsageError(f"Invalid format template: {exc}") from exc + unknown = fields - ALLOWED_FIELDS + if unknown: + raise UsageError(f"Unknown format field(s): {', '.join(sorted(unknown))}") + + +def format_values(snapshot: dict[str, Any], now: int) -> tuple[dict[str, Any], int]: + five_hour, weekly = select_windows(snapshot) + five_used = int(five_hour.get("usedPercent", 0)) + weekly_used = int(weekly.get("usedPercent", 0)) + values = { + "five_hour_used": five_used, + "weekly_used": weekly_used, + "five_hour_reset_in": human_countdown(five_hour.get("resetsAt"), now), + "weekly_reset_in": human_countdown(weekly.get("resetsAt"), now), + "five_hour_reset_at": reset_time(five_hour.get("resetsAt")), + "weekly_reset_at": reset_time(weekly.get("resetsAt")), + "plan": snapshot.get("planType") or "unknown", + } + return values, max(five_used, weekly_used) + + +def output_json( + result: dict[str, Any], + text_format: str, + tooltip_format: str, + warning: int, + critical: int, + stale: bool = False, + stale_reason: str | None = None, + now: int | None = None, +) -> dict[str, Any]: + snapshot = select_snapshot(result) + values, percentage = format_values(snapshot, now or int(time.time())) + classes = ["critical" if percentage >= critical else "warning" if percentage >= warning else "normal"] + if stale: + classes.append("stale") + tooltip = tooltip_format.format_map(values) + if stale: + tooltip += f"\n\nCached data: {stale_reason or 'refresh failed'}" + return { + "text": html.escape(text_format.format_map(values)), + "tooltip": html.escape(tooltip), + "class": classes, + "percentage": percentage, + } + + +def unavailable_json(reason: str) -> dict[str, Any]: + return { + "text": "Codex ?", + "tooltip": html.escape(f"Codex usage unavailable\n{reason}"), + "class": ["unavailable"], + "percentage": 0, + } + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--format", default=DEFAULT_FORMAT, help="Waybar text template") + parser.add_argument("--tooltip-format", default=DEFAULT_TOOLTIP, help="Waybar tooltip template") + parser.add_argument("--warning", type=int, default=70, help="Warning threshold percentage") + parser.add_argument("--critical", type=int, default=90, help="Critical threshold percentage") + parser.add_argument("--timeout", type=float, default=10, help="Codex app-server timeout in seconds") + parser.add_argument("--codex-command", default="codex", help=argparse.SUPPRESS) + parser.add_argument("--no-cache", action="store_true", help="Disable cached fallback data") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + validate_template(args.format) + validate_template(args.tooltip_format) + if not 0 <= args.warning <= args.critical <= 100: + raise UsageError("thresholds must satisfy 0 <= warning <= critical <= 100") + except UsageError as exc: + print(json.dumps(unavailable_json(str(exc)), separators=(",", ":"))) + return 2 + + path = cache_path() + try: + result = fetch_rate_limits(args.timeout, args.codex_command) + save_cache(path, result) + output = output_json(result, args.format, args.tooltip_format, args.warning, args.critical) + except UsageError as exc: + cached = None if args.no_cache else load_cache(path) + cached_result = cached.get("result") if isinstance(cached, dict) else None + try: + if not isinstance(cached_result, dict): + raise UsageError(str(exc)) + output = output_json( + cached_result, + args.format, + args.tooltip_format, + args.warning, + args.critical, + stale=True, + stale_reason=str(exc), + ) + except UsageError: + output = unavailable_json(str(exc)) + + print(json.dumps(output, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/codex-waybar-configure b/bin/codex-waybar-configure new file mode 100755 index 0000000..d786e9a --- /dev/null +++ b/bin/codex-waybar-configure @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Safely add the Codex usage module to an existing Waybar JSONC config.""" + +from __future__ import annotations + +import argparse +import re +import shutil +import sys +from datetime import datetime +from pathlib import Path + +MODULE_NAME = "custom/codex-usage" +MODULE_BLOCK = ''' "custom/codex-usage": { + "exec": "codex-waybar", + "return-type": "json", + "interval": 60, + "tooltip": true + } +''' +CSS_BLOCK = """ + +/* Codex usage module */ +#custom-codex-usage { + min-width: 12px; + margin: 0 7.5px; +} + +#custom-codex-usage.warning { + color: #d8a657; +} + +#custom-codex-usage.critical, +#custom-codex-usage.unavailable { + color: #a55555; +} + +#custom-codex-usage.stale { + opacity: 0.65; +} +""" + + +class ConfigureError(RuntimeError): + pass + + +def backup(path: Path) -> Path: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + destination = path.with_name(f"{path.name}.bak.{stamp}") + shutil.copy2(path, destination) + return destination + + +def insert_module_reference(text: str, section: str) -> str: + pattern = re.compile(rf'("modules-{section}"\s*:\s*\[)(.*?)(\])', re.DOTALL) + matches = list(pattern.finditer(text)) + if len(matches) != 1: + raise ConfigureError(f'Could not uniquely locate "modules-{section}"') + match = matches[0] + if f'"{MODULE_NAME}"' in match.group(2): + return text + return text[: match.end(1)] + f'"{MODULE_NAME}", ' + text[match.end(1) :] + + +def insert_module_block(text: str) -> str: + if re.search(rf'"{re.escape(MODULE_NAME)}"\s*:', text): + return text + close = text.rfind("}") + if close < 0: + raise ConfigureError("Could not locate the top-level closing brace") + before = text[:close].rstrip() + comma = "" if before.endswith("{") else "," + return f"{before}{comma}\n{MODULE_BLOCK}{text[close:]}" + + +def configure(config_path: Path, style_path: Path, section: str) -> tuple[Path | None, Path | None]: + if not config_path.is_file(): + raise ConfigureError(f"Waybar config not found: {config_path}") + original = config_path.read_text() + updated = insert_module_block(insert_module_reference(original, section)) + config_backup = None + if updated != original: + config_backup = backup(config_path) + config_path.write_text(updated) + + style_backup = None + if style_path.exists(): + style = style_path.read_text() + if "#custom-codex-usage" not in style: + style_backup = backup(style_path) + style_path.write_text(style.rstrip() + CSS_BLOCK) + else: + style_path.parent.mkdir(parents=True, exist_ok=True) + style_path.write_text(CSS_BLOCK.lstrip()) + return config_backup, style_backup + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + default_dir = Path.home() / ".config" / "waybar" + parser.add_argument("--section", choices=("left", "center", "right"), required=True) + parser.add_argument("--config", type=Path, default=default_dir / "config.jsonc") + parser.add_argument("--style", type=Path, default=default_dir / "style.css") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + config_backup, style_backup = configure(args.config, args.style, args.section) + except (OSError, ConfigureError) as exc: + print(f"codex-waybar-configure: {exc}", file=sys.stderr) + return 1 + print(f"Configured {args.config}") + if config_backup: + print(f"Backup: {config_backup}") + if style_backup: + print(f"Backup: {style_backup}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f24eb0e --- /dev/null +++ b/install.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +BIN_DIR="${HOME}/.local/bin" +CONFIGURE_WAYBAR=false +SECTION="" +RESTART=false + +usage() { + cat <<'EOF' +Usage: ./install.sh [options] + +Options: + --configure-waybar Add the module to ~/.config/waybar/config.jsonc + --section SECTION Placement: left, center, or right + --restart Restart Waybar after configuration + -h, --help Show this help +EOF +} + +while (($#)); do + case "$1" in + --configure-waybar) CONFIGURE_WAYBAR=true ;; + --section) + SECTION="${2:-}" + shift + ;; + --restart) RESTART=true ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +install -d "$BIN_DIR" +install -m 0755 "$ROOT/bin/codex-waybar" "$BIN_DIR/codex-waybar" +install -m 0755 "$ROOT/bin/codex-waybar-configure" "$BIN_DIR/codex-waybar-configure" +echo "Installed codex-waybar commands in $BIN_DIR" + +if [[ "$CONFIGURE_WAYBAR" == true ]]; then + if [[ -z "$SECTION" ]]; then + read -r -p "Waybar section [right]: " SECTION + SECTION="${SECTION:-right}" + fi + case "$SECTION" in + left|center|right) ;; + *) + echo "Section must be left, center, or right" >&2 + exit 2 + ;; + esac + "$BIN_DIR/codex-waybar-configure" --section "$SECTION" +fi + +if [[ "$RESTART" == true ]]; then + if command -v omarchy >/dev/null 2>&1; then + omarchy restart waybar + else + pkill -SIGUSR2 waybar + fi +fi diff --git a/tests/test_codex_waybar.py b/tests/test_codex_waybar.py new file mode 100644 index 0000000..2ae2c6c --- /dev/null +++ b/tests/test_codex_waybar.py @@ -0,0 +1,169 @@ +import importlib.util +from importlib.machinery import SourceFileLoader +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + + +def load_script(name, path): + spec = importlib.util.spec_from_loader(name, SourceFileLoader(name, str(path))) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +ROOT = Path(__file__).resolve().parents[1] +module = load_script("codex_waybar", ROOT / "bin" / "codex-waybar") +configure = load_script("codex_waybar_configure", ROOT / "bin" / "codex-waybar-configure") + + +def rate_result(five_used=22, weekly_used=18): + return { + "rateLimits": { + "limitId": "codex", + "planType": "plus", + "primary": { + "usedPercent": five_used, + "windowDurationMins": 300, + "resetsAt": 10_000, + }, + "secondary": { + "usedPercent": weekly_used, + "windowDurationMins": 10_080, + "resetsAt": 200_000, + }, + } + } + + +class UsageModuleTests(unittest.TestCase): + def test_parse_json_lines_ignores_notifications(self): + output = "\n".join( + [ + '{"id":1,"result":{}}', + '{"method":"notification","params":{}}', + json.dumps({"id": 2, "result": rate_result()}), + ] + ) + self.assertEqual(module.parse_json_lines(output), rate_result()) + + def test_selects_windows_by_duration_not_position(self): + result = rate_result() + snapshot = result["rateLimits"] + snapshot["primary"], snapshot["secondary"] = snapshot["secondary"], snapshot["primary"] + values, percentage = module.format_values(snapshot, now=1_000) + self.assertEqual(values["five_hour_used"], 22) + self.assertEqual(values["weekly_used"], 18) + self.assertEqual(percentage, 22) + + def test_countdown_formats_and_clamps(self): + self.assertEqual(module.human_countdown(1_000, 1_000), "0m") + self.assertEqual(module.human_countdown(4_601, 1_000), "1h 01m") + self.assertEqual(module.human_countdown(91_000, 1_000), "1d 1h") + self.assertEqual(module.human_countdown(999, 1_000), "0m") + + def test_output_sets_classes_and_custom_format(self): + output = module.output_json( + rate_result(five_used=72), + "{five_hour_used}/{weekly_used} {five_hour_reset_in}", + "{plan}", + warning=70, + critical=90, + now=1_000, + ) + self.assertEqual(output["text"], "72/18 2h 30m") + self.assertEqual(output["tooltip"], "plus") + self.assertEqual(output["class"], ["warning"]) + self.assertEqual(output["percentage"], 72) + + def test_missing_window_is_rejected(self): + result = rate_result() + result["rateLimits"]["secondary"] = None + with self.assertRaises(module.UsageError): + module.output_json(result, module.DEFAULT_FORMAT, module.DEFAULT_TOOLTIP, 70, 90) + + def test_unknown_template_field_is_rejected(self): + with self.assertRaises(module.UsageError): + module.validate_template("{not_a_field}") + + def test_main_uses_stale_cache_after_fetch_failure(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "codex-waybar" / "rate-limits.json" + module.save_cache(path, rate_result()) + stdout = io.StringIO() + with ( + patch.dict(os.environ, {"XDG_CACHE_HOME": tmp}), + patch.object(module, "fetch_rate_limits", side_effect=module.UsageError("offline")), + redirect_stdout(stdout), + ): + exit_code = module.main(["--format", "{five_hour_used}%"]) + output = json.loads(stdout.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(output["text"], "22%") + self.assertIn("stale", output["class"]) + + def test_main_returns_unavailable_without_cache(self): + with tempfile.TemporaryDirectory() as tmp: + stdout = io.StringIO() + with ( + patch.dict(os.environ, {"XDG_CACHE_HOME": tmp}), + patch.object(module, "fetch_rate_limits", side_effect=module.UsageError("offline")), + redirect_stdout(stdout), + ): + module.main([]) + output = json.loads(stdout.getvalue()) + self.assertEqual(output["class"], ["unavailable"]) + self.assertIn("offline", output["tooltip"]) + + +class ConfigureTests(unittest.TestCase): + CONFIG = """{ + // Existing comment + "modules-left": ["clock"], + "modules-center": [], + "modules-right": [ + "cpu" + ], + "cpu": {"interval": 5} +} +""" + + def test_configure_preserves_comments_and_is_idempotent(self): + with tempfile.TemporaryDirectory() as tmp: + config = Path(tmp) / "config.jsonc" + style = Path(tmp) / "style.css" + config.write_text(self.CONFIG) + style.write_text("* { color: white; }\n") + + configure.configure(config, style, "right") + first_config = config.read_text() + first_style = style.read_text() + configure.configure(config, style, "right") + + self.assertIn("// Existing comment", first_config) + self.assertEqual(first_config.count('"custom/codex-usage"'), 2) + self.assertEqual(config.read_text(), first_config) + self.assertEqual(style.read_text(), first_style) + self.assertEqual(first_style.count("#custom-codex-usage"), 5) + + def test_ambiguous_section_aborts_before_write(self): + with tempfile.TemporaryDirectory() as tmp: + config = Path(tmp) / "config.jsonc" + style = Path(tmp) / "style.css" + original = '{"modules-right": [], "modules-right": []}\n' + config.write_text(original) + style.write_text("") + with self.assertRaises(configure.ConfigureError): + configure.configure(config, style, "right") + self.assertEqual(config.read_text(), original) + + +if __name__ == "__main__": + unittest.main()