-
Notifications
You must be signed in to change notification settings - Fork 33
feat(contrib): SwiftBar menu bar quota plugin for macOS #603
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| # SwiftBar Quota Plugin (macOS) | ||
|
|
||
| A menu bar widget for [SwiftBar](https://github.com/swiftbar/SwiftBar) that shows | ||
| per-account Codex quota from the local codex-multi-auth cache. | ||
|
|
||
| ```text | ||
| ⚡88·[90] ← menu bar title: 5h-window remaining % per account, | ||
| brackets mark the account currently serving requests | ||
| ``` | ||
|
|
||
| Opening the menu renders one card per managed account: | ||
|
|
||
| ```text | ||
| ╭──────────────────────────────────╮ | ||
| │ ● account-a ACTIVE │ | ||
| │ 5h █████████░ 88% → 4h 42m │ | ||
| │ 7d ████████░░ 83% → 6d 18h │ | ||
| ╰──────────────────────────────────╯ | ||
| ╭──────────────────────────────────╮ | ||
| │ ○ account-b IDLE │ | ||
| │ 5h █████████░ 90% → 4h 33m │ | ||
| │ 7d █████████░ 86% → 8h 16m │ | ||
| ╰──────────────────────────────────╯ | ||
| ``` | ||
|
|
||
| - Green card / `●` / `ACTIVE`: the account the runtime rotation router last | ||
| served a request with (falls back to the stored active index). | ||
| - `5h` / `7d`: the two Codex quota windows — bar, remaining percent, and a | ||
| countdown to the window reset (`4h 42m` style). | ||
| - Rows turn orange below 30% remaining and red below 10%. | ||
|
|
||
| ## Install | ||
|
|
||
| ```bash | ||
| brew install --cask swiftbar # if not installed | ||
| mkdir -p ~/.swiftbar-plugins | ||
| cp contrib/swiftbar/codex-quota.5m.sh ~/.swiftbar-plugins/ | ||
| chmod +x ~/.swiftbar-plugins/codex-quota.5m.sh | ||
| open -a SwiftBar # pick ~/.swiftbar-plugins as the plugin folder | ||
| ``` | ||
|
|
||
| ## Data source and refresh model | ||
|
|
||
| The plugin reads the local quota cache (`quota-cache.json`), account store, and | ||
| runtime observability files under `~/.codex/multi-auth/` (or | ||
| `CODEX_MULTI_AUTH_DIR`). Reading the cache costs **zero quota**: the cache is | ||
| updated passively from the rate-limit headers of real Codex traffic flowing | ||
| through the rotation proxy, and by explicit live checks. | ||
|
|
||
| - The `5m` in the filename is SwiftBar's re-read interval (cache only). | ||
| - The menu re-reads the cache every time it is opened (`refreshOnOpen`). | ||
| - **Live refresh** in the menu runs `codex-multi-auth check`, which sends one | ||
| minimal probe per account and consumes a small amount of quota. | ||
|
|
||
| ## Notes | ||
|
|
||
| - macOS only (SwiftBar). Card borders require a Menlo-native glyph set; if you | ||
| edit the card interior, avoid CJK or emoji — they render in a fallback font | ||
| with non-integer widths and break the right border alignment. | ||
| - The cache file formats are internal to codex-multi-auth and may change | ||
| between versions; the plugin fails soft (shows `⚡?`) when they do. | ||
| - Account names shown are the local-part of each account email. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| #!/bin/bash | ||
| # <swiftbar.title>Codex Multi-Auth Quota</swiftbar.title> | ||
| # <swiftbar.version>v1.0</swiftbar.version> | ||
| # <swiftbar.author>codex-multi-auth contributors</swiftbar.author> | ||
| # <swiftbar.about>macOS menu bar cards for codex-multi-auth account quota (5h/7d windows, active-account marker, reset countdowns)</swiftbar.about> | ||
| # <swiftbar.dependencies>codex-multi-auth,python3</swiftbar.dependencies> | ||
| # <swiftbar.refreshOnOpen>true</swiftbar.refreshOnOpen> | ||
| # <swiftbar.runInBash>true</swiftbar.runInBash> | ||
| # | ||
| # Reads the local codex-multi-auth quota cache (zero quota cost). The cache is | ||
| # written by quota-bearing commands (`check`, `forecast --live`, the interactive | ||
| # dashboard); the "Live refresh" menu item runs one such check, sending a | ||
| # minimal probe per account. | ||
|
|
||
| if [ "$(uname -s)" != "Darwin" ]; then | ||
| echo "⚡" | ||
| echo "---" | ||
| echo "Codex quota plugin requires macOS (SwiftBar) | color=gray" | ||
| exit 0 | ||
| fi | ||
|
|
||
| if [ "$1" = "livecheck" ]; then | ||
| export PATH="/usr/local/bin:/opt/homebrew/bin:$HOME/.npm-global/bin:$PATH" | ||
| codex-multi-auth check | ||
| exit $? | ||
| fi | ||
|
|
||
| SELF="$0" | ||
|
|
||
| /usr/bin/python3 - "$SELF" <<'PYEOF' | ||
| import json, os, sys, time | ||
|
|
||
| SELF = sys.argv[1] | ||
| HOME = os.path.expanduser("~") | ||
| DATA_DIR = os.environ.get("CODEX_MULTI_AUTH_DIR") or os.path.join(HOME, ".codex", "multi-auth") | ||
| CACHE = os.path.join(DATA_DIR, "quota-cache.json") | ||
| STORE = os.path.join(DATA_DIR, "openai-codex-accounts.json") | ||
| OBSERV = os.path.join(DATA_DIR, "runtime-observability.json") | ||
|
|
||
| GREEN = "#34C759" | ||
| GRAY = "#9A9A9E" | ||
| RED = "#FF3B30" | ||
| ORANGE = "#FF9F0A" | ||
| # Card interior must stay within Menlo-native glyphs (ASCII, box drawing, | ||
| # block elements, arrows, geometric circles). CJK or emoji fall back to other | ||
| # fonts with non-integer widths and break the right border alignment. | ||
| W = 32 | ||
|
|
||
| def load(path): | ||
| try: | ||
| with open(path) as f: | ||
| return json.load(f) | ||
| except Exception: | ||
| return None | ||
|
|
||
| def pad(s, width): | ||
| return s + " " * max(0, width - len(s)) | ||
|
|
||
| def fmt_reset(ms): | ||
| left = int(ms / 1000 - time.time()) | ||
| if left <= 0: | ||
| return "now" | ||
| d, r = divmod(left, 86400) | ||
| h, r = divmod(r, 3600) | ||
| m = r // 60 | ||
| if d > 0: | ||
| return f"{d}d {h}h" | ||
| if h > 0: | ||
| return f"{h}h {m}m" | ||
| return f"{m}m" | ||
|
|
||
| def clamp_pct(value): | ||
| try: | ||
| return max(0, min(100, int(value))) | ||
| except (TypeError, ValueError): | ||
| return 0 | ||
|
|
||
| def bar(remaining, slots=10): | ||
| filled = round(remaining / 100 * slots) | ||
| return "█" * filled + "░" * (slots - filled) | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| def quota_color(rem): | ||
| if rem < 10: return RED | ||
| if rem < 30: return ORANGE | ||
| return None | ||
|
|
||
| cache = load(CACHE) | ||
| store = load(STORE) | ||
| if not cache or not store: | ||
| print("⚡?") | ||
| print("---") | ||
| print("Cannot read quota cache or account store | color=red") | ||
| print(f"Expected under: {DATA_DIR} | size=11 color=gray") | ||
| sys.exit(0) | ||
|
|
||
| emails, order = {}, [] | ||
| for acc in store.get("accounts", []): | ||
| aid = acc.get("accountId", "") | ||
| emails[aid] = acc.get("email", aid[-6:] if aid else "?") | ||
| order.append(aid) | ||
|
|
||
| by_id = cache.get("byAccountId", {}) | ||
|
|
||
| observ = load(OBSERV) or {} | ||
|
Comment on lines
+87
to
+104
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: contrib/swiftbar/codex-quota.5m.sh
Line: 73-90
Comment:
**Concurrent reads across three separate files**
`quota-cache.json`, `openai-codex-accounts.json`, and `runtime-observability.json` are opened sequentially with no advisory lock. the rotation proxy can write any of these between reads, so `order` (from the accounts store) and `by_id` (from the quota cache) can be momentarily inconsistent — a freshly-added account appears in `order` but has no entry in `by_id`, producing a `"?"` quota row. the `load()` except guard handles full parse failures, but not this cross-file ordering gap. for a 5 m plugin this is cosmetic, but flagging explicitly since the codebase has active concurrent writers.
How can I resolve this? If you propose a fix, please make it concise. |
||
| active_id = observ.get("lastAccountId") | ||
| if active_id not in order: | ||
| idx = store.get("activeIndex") | ||
| active_id = order[idx] if isinstance(idx, int) and 0 <= idx < len(order) else None | ||
|
|
||
| titles, blocks = [], [] | ||
| newest = 0 | ||
| for aid in order: | ||
| email = emails.get(aid, "?") | ||
| short = email.split("@")[0] | ||
| is_active = (aid == active_id) | ||
| frame = GREEN if is_active else GRAY | ||
| dot = "●" if is_active else "○" | ||
| tag = "ACTIVE" if is_active else "IDLE" | ||
| rows = [(pad(f"{dot} {short}", W - len(tag)) + tag, None)] | ||
| q = by_id.get(aid) | ||
| if not q: | ||
| titles.append("?") | ||
| rows.append((pad("no quota data", W), None)) | ||
| else: | ||
| newest = max(newest, q.get("updatedAt", 0)) | ||
| for key, label in (("primary", "5h"), ("secondary", "7d")): | ||
| win = q.get(key, {}) | ||
| rem = clamp_pct(100 - clamp_pct(win.get("usedPercent", 0))) | ||
| reset = fmt_reset(win["resetAtMs"]) if win.get("resetAtMs") else "-" | ||
| rows.append((pad(f"{label} {bar(rem)} {rem:>3}% → {reset}", W), quota_color(rem))) | ||
| if key == "primary": | ||
| titles.append(f"[{rem}]" if is_active else str(rem)) | ||
| blocks.append((frame, rows)) | ||
|
|
||
| print("⚡" + "·".join(titles)) | ||
| print("---") | ||
| style = "font=Menlo size=12 trim=false emojize=false" | ||
| for frame, rows in blocks: | ||
| print(f"╭{'─' * (W + 2)}╮ | color={frame} {style}") | ||
| for text, override in rows: | ||
| print(f"│ {text} │ | color={override or frame} {style}") | ||
| print(f"╰{'─' * (W + 2)}╯ | color={frame} {style}") | ||
| if newest: | ||
| age_min = int((time.time() - newest / 1000) / 60) | ||
| age = "just now" if age_min < 1 else (f"{age_min}m ago" if age_min < 60 else f"{age_min//60}h ago") | ||
| print(f"Cache updated {age} | size=11 color=gray") | ||
| print(f"Live refresh (one probe per account) | bash={SELF} param1=livecheck terminal=false refresh=true") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: contrib/swiftbar/codex-quota.5m.sh
Line: 133
Comment:
**Unquoted `SELF` path in SwiftBar action**
`bash={SELF}` is written without quotes; if the plugin is installed in a path with spaces (e.g. a home dir like `/Users/John Doe/`) SwiftBar's parameter parser splits on whitespace and treats everything after the first space as a new param, silently dropping the `livecheck` dispatch and breaking the live-refresh action. SwiftBar supports `bash="{SELF}"` (or percent-encoding), which handles spaces cleanly.
How can I resolve this? If you propose a fix, please make it concise. |
||
| print("Reload from cache | refresh=true") | ||
| PYEOF | ||
|
Comment on lines
+30
to
+149
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | 🏗️ Heavy lift add regression coverage for quota parsing/render formatting. this feature ships non-trivial transform/render logic with internal-cache compatibility assumptions, but this pr has no tests for: active-account fallback, color thresholds ( 🤖 Prompt for AI Agents |
||
Uh oh!
There was an error while loading. Please reload this page.