init
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.idea/
|
||||||
159
README.md
Normal file
159
README.md
Normal file
@@ -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.
|
||||||
311
bin/codex-waybar
Executable file
311
bin/codex-waybar
Executable file
@@ -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())
|
||||||
124
bin/codex-waybar-configure
Executable file
124
bin/codex-waybar-configure
Executable file
@@ -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())
|
||||||
69
install.sh
Executable file
69
install.sh
Executable file
@@ -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
|
||||||
169
tests/test_codex_waybar.py
Normal file
169
tests/test_codex_waybar.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user