feat: SuperGrok weekly collector for stock Omarchy agents panel
Writes grok.json for omarchy.agents. Yields when official omarchy-agent-usage-grok exists. No bar plugin, no packaged-bin name.
This commit is contained in:
commit
4df21f20a0
11 changed files with 935 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.json
|
||||
!tests/**/*.json
|
||||
auth.json
|
||||
grok.json
|
||||
.venv/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 John Morris and Ebeneezer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
86
README.md
Normal file
86
README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Omarchy Grok usage collector
|
||||
|
||||
Household SuperGrok weekly quota for the stock Omarchy agents panel. Not a
|
||||
bar plugin. Not a fork of third-party compositor QML.
|
||||
|
||||
The panel already draws whatever JSON lands in:
|
||||
|
||||
```text
|
||||
~/.local/state/omarchy/agents/usage/<id>.json
|
||||
```
|
||||
|
||||
This repo writes `grok.json` there. When Omarchy later ships
|
||||
`omarchy-agent-usage-grok` under `$OMARCHY_PATH/bin/`, this collector
|
||||
**stops writing** and leaves that file alone.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Omarchy with the Quattro `omarchy.agents` panel
|
||||
- Grok Build CLI logged in (`grok login`) so `~/.grok/auth.json` exists
|
||||
- Python 3.10+ (stdlib only)
|
||||
- `systemd --user`
|
||||
|
||||
It never stores the Grok token in the usage record, cache, or logs. The
|
||||
token is read from the CLI auth file and sent only as `Authorization` to
|
||||
xAI's CLI billing/settings HTTPS endpoints, with redirects refused.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone git@forgejo.jtmorris.net:ebeneezer/omarchy-grok-usage.git
|
||||
cd omarchy-grok-usage
|
||||
python3 -m unittest tests.test_collector -v
|
||||
./install.sh
|
||||
```
|
||||
|
||||
`install.sh` copies the collector to `~/.local/bin/omarchy-grok-usage` and
|
||||
enables a 15-minute user timer. It does not write under `/usr/share/omarchy`
|
||||
and does not name the binary `omarchy-agent-usage-grok`, so Omarchy's
|
||||
`omarchy-agent-usage-update` glob will not pick this up.
|
||||
|
||||
Left-click the agents icon on the bar. A Grok tab appears once
|
||||
`grok.json` is ready (weekly percent is enough; local session charts are
|
||||
out of scope).
|
||||
|
||||
## Coexistence with official collectors
|
||||
|
||||
`omarchy-agent-usage-update` only runs `$OMARCHY_PATH/bin/omarchy-agent-usage-*`.
|
||||
|
||||
| Situation | What happens |
|
||||
| --- | --- |
|
||||
| No official Grok collector | This timer writes `grok.json` |
|
||||
| Official `omarchy-agent-usage-grok` is executable | `--write` exits 0 and does not touch `grok.json` |
|
||||
| You want to keep this writer anyway | `OMARCHY_GROK_USAGE_FORCE=1` |
|
||||
|
||||
After an Omarchy update that ships Grok, you can also `./uninstall.sh`.
|
||||
Uninstall does not delete `grok.json`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
omarchy-grok-usage # print the record on stdout
|
||||
omarchy-grok-usage --write # atomic replace of grok.json, unless yielding
|
||||
systemctl --user status omarchy-grok-usage.timer
|
||||
journalctl --user -u omarchy-grok-usage.service -n 50
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Reads `~/.grok/auth.json` (mode 0600, owned by you)
|
||||
- HTTPS only, no redirect following
|
||||
- Token never copied into the JSON the panel reads
|
||||
- No QML in `omarchy-shell`
|
||||
- No prebuilt binary; you run the Python in this tree
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m unittest tests.test_collector -v
|
||||
```
|
||||
|
||||
Fixtures use fake tokens. Tests do not touch a live `auth.json` or the
|
||||
network.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
13
bin/omarchy-grok-usage
Executable file
13
bin/omarchy-grok-usage
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
#!/usr/bin/python3
|
||||
"""Repo-checkout launcher. `install.sh` copies src/omarchy_grok_usage.py onto PATH instead."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from omarchy_grok_usage import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
52
docs/DEV.md
Normal file
52
docs/DEV.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Agent notes — omarchy-grok-usage
|
||||
|
||||
Household collector. Owner: John Morris. Author on this Forgejo: Ebeneezer.
|
||||
|
||||
## Product lock
|
||||
|
||||
- Fill SuperGrok weekly remaining quota on the stock Omarchy agents panel.
|
||||
- Do not add a Quickshell / bar plugin.
|
||||
- Do not name the installed binary `omarchy-agent-usage-grok`.
|
||||
- Do not patch `/usr/share/omarchy` or `omarchy-agent-usage-update`.
|
||||
- When official `omarchy-agent-usage-grok` exists, `--write` is a no-op.
|
||||
- No secrets in git, tests, README examples, or chat.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
| --- | --- |
|
||||
| `src/omarchy_grok_usage.py` | Collector (stdlib Python) |
|
||||
| `bin/omarchy-grok-usage` | Checkout launcher (`src/` on `sys.path`) |
|
||||
| `install.sh` / `uninstall.sh` | User systemd timer |
|
||||
| `systemd/` | `--user` unit + timer |
|
||||
| `tests/test_collector.py` | unittest, no network |
|
||||
|
||||
Install copies `src/omarchy_grok_usage.py` to `~/.local/bin/omarchy-grok-usage`.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python3 -m unittest tests.test_collector -v
|
||||
```
|
||||
|
||||
On a machine with `grok login` already done:
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
python3 -c 'import json,pathlib; p=pathlib.Path.home()/".local/state/omarchy/agents/usage/grok.json"; d=json.loads(p.read_text()); assert d["id"]=="grok"; assert "limits" in d; print(d["tierLabel"], d["ready"], [x.get("percent") for x in d["limits"]])'
|
||||
```
|
||||
|
||||
Do not print `auth.json`. Do not dump Authorization headers.
|
||||
|
||||
## Git
|
||||
|
||||
- Public repo is fine. Never commit `auth.json`, live `grok.json`, or tokens.
|
||||
- Forgejo: `ebeneezer/omarchy-grok-usage`. John (`jtmorris`) is an admin collaborator.
|
||||
- Git author for agent commits: `Ebeneezer <ebeneezer@jtmorris.net>`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Claude / Codex / Amp / Cursor
|
||||
- Session token charts from `~/.grok/sessions`
|
||||
- Chips drawn on the bar itself
|
||||
- Shipping this as an Omarchy plugin
|
||||
27
install.sh
Executable file
27
install.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install the household Grok usage collector for the current user.
|
||||
# Does not touch /usr/share/omarchy and does not install a bar plugin.
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
src="$root/src/omarchy_grok_usage.py"
|
||||
bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
||||
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "missing collector source: $src" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$bin_dir" "$unit_dir"
|
||||
install -m 0755 "$src" "$bin_dir/omarchy-grok-usage"
|
||||
install -m 0644 "$root/systemd/omarchy-grok-usage.service" "$unit_dir/omarchy-grok-usage.service"
|
||||
install -m 0644 "$root/systemd/omarchy-grok-usage.timer" "$unit_dir/omarchy-grok-usage.timer"
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now omarchy-grok-usage.timer
|
||||
systemctl --user start omarchy-grok-usage.service
|
||||
|
||||
echo "Installed $bin_dir/omarchy-grok-usage"
|
||||
echo "Timer: systemctl --user status omarchy-grok-usage.timer"
|
||||
echo "If an official omarchy-agent-usage-grok later ships, this timer becomes a no-op."
|
||||
412
src/omarchy_grok_usage.py
Executable file
412
src/omarchy_grok_usage.py
Executable file
|
|
@ -0,0 +1,412 @@
|
|||
#!/usr/bin/python3
|
||||
"""Household SuperGrok weekly-quota collector for Omarchy's agents panel.
|
||||
|
||||
Prints (or writes) one display-ready JSON record with id ``grok``. The stock
|
||||
``omarchy.agents`` panel already watches
|
||||
``~/.local/state/omarchy/agents/usage/``. This program does not install a
|
||||
bar plugin and is not named ``omarchy-agent-usage-grok``, so a future
|
||||
packaged Omarchy collector wins the glob in ``omarchy-agent-usage-update``.
|
||||
|
||||
When an official ``omarchy-agent-usage-grok`` executable is present, ``--write``
|
||||
is a no-op and leaves ``grok.json`` alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
AGENT_ID = "grok"
|
||||
AGENT_NAME = "Grok"
|
||||
AUTH_HELP = "Run `grok login` to restore SuperGrok usage."
|
||||
BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"
|
||||
SETTINGS_URL = "https://cli-chat-proxy.grok.com/v1/settings"
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
HttpGet = Callable[[str, Mapping[str, str]], dict[str, Any]]
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
"""HTTPS transport, redirect, or protocol failure. Never includes headers."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriteResult:
|
||||
action: str
|
||||
path: Path | None = None
|
||||
|
||||
|
||||
def parse_auth(payload: Any) -> tuple[str | None, str | None]:
|
||||
"""Return (token, optional first_name) from Grok CLI ``auth.json``."""
|
||||
if not isinstance(payload, dict):
|
||||
return None, None
|
||||
for entry in payload.values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
key = entry.get("key")
|
||||
if isinstance(key, str) and key.strip():
|
||||
name = entry.get("first_name")
|
||||
label = name.strip() if isinstance(name, str) and name.strip() else None
|
||||
return key, label
|
||||
return None, None
|
||||
|
||||
|
||||
def _numeric(value: Any) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, dict) and "val" in value:
|
||||
return _numeric(value.get("val"))
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _iso_reset(value: Any) -> str | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
raw = value.strip().replace("Z", "+00:00")
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
||||
return parsed.isoformat()
|
||||
|
||||
|
||||
def limits_from_billing(payload: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
config = payload.get("config") if isinstance(payload.get("config"), dict) else payload
|
||||
if not isinstance(config, dict):
|
||||
return []
|
||||
|
||||
percent = _numeric(config.get("creditUsagePercent"))
|
||||
if percent is None:
|
||||
used = _numeric(config.get("used"))
|
||||
limit = _numeric(config.get("monthlyLimit"))
|
||||
if used is not None and limit and limit > 0:
|
||||
percent = (used / limit) * 100.0
|
||||
if percent is None:
|
||||
return []
|
||||
percent = max(0.0, min(100.0, percent))
|
||||
|
||||
period = config.get("currentPeriod")
|
||||
end = None
|
||||
if isinstance(period, dict):
|
||||
end = period.get("end")
|
||||
if end is None:
|
||||
end = config.get("billingPeriodEnd")
|
||||
|
||||
return [
|
||||
{
|
||||
"label": "Weekly",
|
||||
"percent": percent,
|
||||
"resetsAt": _iso_reset(end),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _humanize_tier(label: str) -> str:
|
||||
known = {
|
||||
"SuperGrokPro": "SuperGrok Pro",
|
||||
"SuperGrokHeavy": "SuperGrok Heavy",
|
||||
"SuperGrokPlus": "SuperGrok Plus",
|
||||
"SuperGrok": "SuperGrok",
|
||||
}
|
||||
if label in known:
|
||||
return known[label]
|
||||
match = re.match(r"^(.*?)(Pro|Heavy|Plus)$", label)
|
||||
if match and match.group(1):
|
||||
return f"{match.group(1)} {match.group(2)}"
|
||||
return label
|
||||
|
||||
|
||||
def tier_from_settings(payload: Mapping[str, Any]) -> str:
|
||||
display = payload.get("subscription_tier_display")
|
||||
if isinstance(display, str) and display.strip():
|
||||
return display.strip()
|
||||
tier = payload.get("subscriptionTier")
|
||||
if isinstance(tier, str) and tier.strip():
|
||||
return _humanize_tier(tier.strip())
|
||||
return ""
|
||||
|
||||
|
||||
def official_grok_collector_present(
|
||||
env: Mapping[str, str] | None = None,
|
||||
extra_paths: tuple[str, ...] = (),
|
||||
) -> bool:
|
||||
env = os.environ if env is None else env
|
||||
if str(env.get("OMARCHY_GROK_USAGE_FORCE", "")) == "1":
|
||||
return False
|
||||
omarchy = str(env.get("OMARCHY_PATH") or "").rstrip("/")
|
||||
candidates = [
|
||||
f"{omarchy}/bin/omarchy-agent-usage-grok" if omarchy else "",
|
||||
"/usr/share/omarchy/bin/omarchy-agent-usage-grok",
|
||||
"/usr/bin/omarchy-agent-usage-grok",
|
||||
*extra_paths,
|
||||
]
|
||||
seen: set[str] = set()
|
||||
for raw in candidates:
|
||||
if not raw or raw in seen:
|
||||
continue
|
||||
seen.add(raw)
|
||||
path = Path(raw)
|
||||
try:
|
||||
if path.is_file() and os.access(path, os.X_OK):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def empty_stats() -> dict[str, Any]:
|
||||
return {
|
||||
"todayPrompts": 0,
|
||||
"todaySessions": 0,
|
||||
"todayTotalTokens": 0,
|
||||
"todayTokensByModel": {},
|
||||
"recentDays": [],
|
||||
"totalPrompts": 0,
|
||||
"totalSessions": 0,
|
||||
"activeDays": 0,
|
||||
"activeDates": [],
|
||||
"modelUsage": {},
|
||||
"hasPromptStats": False,
|
||||
}
|
||||
|
||||
|
||||
def build_record(
|
||||
*,
|
||||
limits: list[dict[str, Any]],
|
||||
tier_label: str,
|
||||
usage_status_text: str,
|
||||
auth_help_text: str,
|
||||
now_iso: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
stamp = now_iso or dt.datetime.now(dt.timezone.utc).isoformat()
|
||||
record: dict[str, Any] = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"id": AGENT_ID,
|
||||
"name": AGENT_NAME,
|
||||
"updatedAt": stamp,
|
||||
"ready": len(limits) > 0,
|
||||
"hasLocalStats": False,
|
||||
"scope": "account",
|
||||
"tierLabel": tier_label,
|
||||
"usageStatusText": usage_status_text,
|
||||
"authHelpText": auth_help_text,
|
||||
"limits": limits,
|
||||
}
|
||||
record.update(empty_stats())
|
||||
return record
|
||||
|
||||
|
||||
def maybe_write_record(
|
||||
*,
|
||||
record: Mapping[str, Any],
|
||||
usage_dir: Path,
|
||||
yield_to_official: bool,
|
||||
) -> WriteResult:
|
||||
target = usage_dir / "grok.json"
|
||||
if yield_to_official:
|
||||
return WriteResult(action="yielded", path=target)
|
||||
usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".grok.", dir=str(usage_dir), text=True)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(record, handle, separators=(",", ":"), sort_keys=True)
|
||||
handle.write("\n")
|
||||
tmp_path.replace(target)
|
||||
except Exception:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return WriteResult(action="wrote", path=target)
|
||||
|
||||
|
||||
def _urlopen(request: urllib.request.Request, timeout: int = 10):
|
||||
opener = urllib.request.build_opener(_NoRedirectHandler)
|
||||
return opener.open(request, timeout=timeout)
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
|
||||
raise HttpError(f"redirect refused: {newurl}")
|
||||
|
||||
|
||||
def https_get(url: str, headers: Mapping[str, str], timeout: int = 10) -> dict[str, Any]:
|
||||
if not url.startswith("https://"):
|
||||
raise HttpError("only https URLs are allowed")
|
||||
request = urllib.request.Request(url, headers=dict(headers))
|
||||
try:
|
||||
with _urlopen(request, timeout=timeout) as response:
|
||||
final = response.geturl()
|
||||
if final.split("#", 1)[0] != url:
|
||||
raise HttpError(f"redirect refused: {final}")
|
||||
raw = response.read()
|
||||
except HttpError:
|
||||
raise
|
||||
except urllib.error.HTTPError as error:
|
||||
raise HttpError(f"http {error.code}") from None
|
||||
except Exception as error:
|
||||
raise HttpError("network error") from error
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except json.JSONDecodeError as error:
|
||||
raise HttpError("invalid json") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise HttpError("invalid json")
|
||||
return payload
|
||||
|
||||
|
||||
def grok_home(env: Mapping[str, str], home: Path) -> Path:
|
||||
raw = env.get("GROK_HOME")
|
||||
if raw:
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
raise ValueError("GROK_HOME must be an absolute path")
|
||||
return path
|
||||
return home / ".grok"
|
||||
|
||||
|
||||
def load_auth_file(path: Path) -> tuple[str | None, str | None]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None, None
|
||||
return parse_auth(payload)
|
||||
|
||||
|
||||
def _auth_headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "omarchy-grok-usage/1.0",
|
||||
}
|
||||
|
||||
|
||||
def collect_record(
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
home: Path | None = None,
|
||||
http_get: HttpGet | None = None,
|
||||
) -> dict[str, Any]:
|
||||
env = os.environ if env is None else env
|
||||
home = Path.home() if home is None else home
|
||||
getter = https_get if http_get is None else http_get
|
||||
|
||||
try:
|
||||
auth_path = grok_home(env, home) / "auth.json"
|
||||
except ValueError:
|
||||
return build_record(
|
||||
limits=[],
|
||||
tier_label="",
|
||||
usage_status_text="GROK_HOME is invalid.",
|
||||
auth_help_text=AUTH_HELP,
|
||||
)
|
||||
|
||||
token, _label = load_auth_file(auth_path)
|
||||
if not token:
|
||||
return build_record(
|
||||
limits=[],
|
||||
tier_label="",
|
||||
usage_status_text="",
|
||||
auth_help_text=AUTH_HELP,
|
||||
)
|
||||
|
||||
headers = _auth_headers(token)
|
||||
try:
|
||||
billing = getter(BILLING_URL, headers)
|
||||
except HttpError as error:
|
||||
message = str(error)
|
||||
if "http 401" in message or "http 403" in message:
|
||||
return build_record(
|
||||
limits=[],
|
||||
tier_label="",
|
||||
usage_status_text="Grok authentication was rejected.",
|
||||
auth_help_text=AUTH_HELP,
|
||||
)
|
||||
return build_record(
|
||||
limits=[],
|
||||
tier_label="",
|
||||
usage_status_text="Couldn't reach Grok billing. Retrying later.",
|
||||
auth_help_text=AUTH_HELP,
|
||||
)
|
||||
|
||||
limits = limits_from_billing(billing)
|
||||
tier = ""
|
||||
try:
|
||||
settings = getter(SETTINGS_URL, headers)
|
||||
tier = tier_from_settings(settings)
|
||||
except HttpError:
|
||||
tier = ""
|
||||
if not tier:
|
||||
tier = "SuperGrok" if limits else ""
|
||||
|
||||
return build_record(
|
||||
limits=limits,
|
||||
tier_label=tier,
|
||||
usage_status_text="" if limits else "Grok billing returned no weekly window.",
|
||||
auth_help_text=AUTH_HELP,
|
||||
)
|
||||
|
||||
|
||||
def default_usage_dir(env: Mapping[str, str], home: Path) -> Path:
|
||||
xdg = env.get("XDG_STATE_HOME")
|
||||
root = Path(xdg) if xdg else home / ".local" / "state"
|
||||
return root / "omarchy" / "agents" / "usage"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Collect SuperGrok weekly usage for the Omarchy agents panel."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--write",
|
||||
action="store_true",
|
||||
help="write grok.json (no-op if an official omarchy-agent-usage-grok exists)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if official_grok_collector_present() and args.write:
|
||||
print(
|
||||
"omarchy-grok-usage: official omarchy-agent-usage-grok present; not writing grok.json",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
record = collect_record()
|
||||
if args.write:
|
||||
result = maybe_write_record(
|
||||
record=record,
|
||||
usage_dir=default_usage_dir(os.environ, Path.home()),
|
||||
yield_to_official=False,
|
||||
)
|
||||
if result.path is not None:
|
||||
print(f"wrote {result.path}", file=sys.stderr)
|
||||
return 0
|
||||
json.dump(record, sys.stdout, separators=(",", ":"), sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
12
systemd/omarchy-grok-usage.service
Normal file
12
systemd/omarchy-grok-usage.service
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[Unit]
|
||||
Description=Refresh SuperGrok usage for the Omarchy agents panel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/.local/bin/omarchy-grok-usage --write
|
||||
Nice=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
12
systemd/omarchy-grok-usage.timer
Normal file
12
systemd/omarchy-grok-usage.timer
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[Unit]
|
||||
Description=Refresh SuperGrok usage every 15 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=15min
|
||||
AccuracySec=1min
|
||||
Persistent=true
|
||||
Unit=omarchy-grok-usage.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
275
tests/test_collector.py
Normal file
275
tests/test_collector.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for the household Grok usage collector.
|
||||
|
||||
No live network. No real credentials. Fixtures use obviously fake tokens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
import omarchy_grok_usage as m # noqa: E402
|
||||
|
||||
FAKE_TOKEN = "fake-test-token-not-a-real-secret"
|
||||
|
||||
|
||||
class TestAuthParse(unittest.TestCase):
|
||||
def test_extracts_key_from_accounts_xai_entry(self) -> None:
|
||||
payload = {
|
||||
"https://accounts.x.ai/sign-in": {
|
||||
"key": FAKE_TOKEN,
|
||||
"first_name": "Test",
|
||||
}
|
||||
}
|
||||
token, label = m.parse_auth(payload)
|
||||
self.assertEqual(token, FAKE_TOKEN)
|
||||
self.assertEqual(label, "Test")
|
||||
|
||||
def test_extracts_key_from_first_object_with_key(self) -> None:
|
||||
payload = {
|
||||
"other": {"nope": 1},
|
||||
"grok-com": {"key": FAKE_TOKEN, "first_name": "Ada"},
|
||||
}
|
||||
token, label = m.parse_auth(payload)
|
||||
self.assertEqual(token, FAKE_TOKEN)
|
||||
self.assertEqual(label, "Ada")
|
||||
|
||||
def test_missing_key_is_unauthenticated(self) -> None:
|
||||
token, label = m.parse_auth({"x": {"refresh": "nope"}})
|
||||
self.assertIsNone(token)
|
||||
self.assertIsNone(label)
|
||||
|
||||
def test_empty_key_is_unauthenticated(self) -> None:
|
||||
token, _label = m.parse_auth({"https://accounts.x.ai/sign-in": {"key": ""}})
|
||||
self.assertIsNone(token)
|
||||
|
||||
|
||||
class TestBillingMap(unittest.TestCase):
|
||||
def test_credit_usage_percent_and_period_end(self) -> None:
|
||||
payload = {
|
||||
"config": {
|
||||
"creditUsagePercent": 31.5,
|
||||
"currentPeriod": {"end": "2026-08-27T00:00:00Z"},
|
||||
"monthlyLimit": {"val": 60000},
|
||||
"used": {"val": 18900},
|
||||
}
|
||||
}
|
||||
limits = m.limits_from_billing(payload)
|
||||
self.assertEqual(len(limits), 1)
|
||||
self.assertEqual(limits[0]["label"], "Weekly")
|
||||
self.assertEqual(limits[0]["percent"], 31.5)
|
||||
self.assertEqual(limits[0]["resetsAt"], "2026-08-27T00:00:00+00:00")
|
||||
|
||||
def test_falls_back_to_used_over_limit(self) -> None:
|
||||
payload = {
|
||||
"config": {
|
||||
"monthlyLimit": {"val": 100},
|
||||
"used": {"val": 40},
|
||||
"billingPeriodEnd": "2026-09-01T00:00:00+00:00",
|
||||
}
|
||||
}
|
||||
limits = m.limits_from_billing(payload)
|
||||
self.assertEqual(limits[0]["percent"], 40.0)
|
||||
self.assertEqual(limits[0]["resetsAt"], "2026-09-01T00:00:00+00:00")
|
||||
|
||||
def test_empty_billing_yields_no_limits(self) -> None:
|
||||
self.assertEqual(m.limits_from_billing({}), [])
|
||||
|
||||
def test_percent_clamped_to_100(self) -> None:
|
||||
payload = {"config": {"creditUsagePercent": 140}}
|
||||
limits = m.limits_from_billing(payload)
|
||||
self.assertEqual(limits[0]["percent"], 100.0)
|
||||
|
||||
|
||||
class TestTierLabel(unittest.TestCase):
|
||||
def test_prefers_subscription_tier_display(self) -> None:
|
||||
self.assertEqual(
|
||||
m.tier_from_settings({"subscription_tier_display": "SuperGrok Heavy"}),
|
||||
"SuperGrok Heavy",
|
||||
)
|
||||
|
||||
def test_splits_camel_subscription_tier(self) -> None:
|
||||
self.assertEqual(
|
||||
m.tier_from_settings({"subscriptionTier": "SuperGrokPro"}),
|
||||
"SuperGrok Pro",
|
||||
)
|
||||
|
||||
def test_empty_settings(self) -> None:
|
||||
self.assertEqual(m.tier_from_settings({}), "")
|
||||
|
||||
|
||||
class TestOfficialYield(unittest.TestCase):
|
||||
def test_yields_when_omarchy_path_collector_exists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
official = Path(tmp) / "bin" / "omarchy-agent-usage-grok"
|
||||
official.parent.mkdir(parents=True)
|
||||
official.write_text("#!/bin/sh\n")
|
||||
official.chmod(official.stat().st_mode | stat.S_IEXEC)
|
||||
env = {"OMARCHY_PATH": tmp}
|
||||
self.assertTrue(m.official_grok_collector_present(env=env))
|
||||
|
||||
def test_absent_when_no_official_binary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env = {
|
||||
"OMARCHY_PATH": tmp,
|
||||
"PATH": tmp,
|
||||
}
|
||||
self.assertFalse(
|
||||
m.official_grok_collector_present(
|
||||
env=env, extra_paths=(str(Path(tmp) / "nope"),)
|
||||
)
|
||||
)
|
||||
|
||||
def test_force_env_disables_yield(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
official = Path(tmp) / "bin" / "omarchy-agent-usage-grok"
|
||||
official.parent.mkdir(parents=True)
|
||||
official.write_text("#!/bin/sh\n")
|
||||
official.chmod(official.stat().st_mode | stat.S_IEXEC)
|
||||
env = {"OMARCHY_PATH": tmp, "OMARCHY_GROK_USAGE_FORCE": "1"}
|
||||
self.assertFalse(m.official_grok_collector_present(env=env))
|
||||
|
||||
|
||||
class TestRecord(unittest.TestCase):
|
||||
def test_token_never_enters_record(self) -> None:
|
||||
record = m.build_record(
|
||||
limits=[{"label": "Weekly", "percent": 10.0, "resetsAt": None}],
|
||||
tier_label="SuperGrok",
|
||||
usage_status_text="",
|
||||
auth_help_text="Run `grok login`.",
|
||||
now_iso="2026-08-20T00:00:00+00:00",
|
||||
)
|
||||
blob = json.dumps(record)
|
||||
self.assertNotIn(FAKE_TOKEN, blob)
|
||||
self.assertNotIn("Authorization", blob)
|
||||
self.assertEqual(record["id"], "grok")
|
||||
self.assertEqual(record["schemaVersion"], 1)
|
||||
self.assertTrue(record["ready"])
|
||||
self.assertEqual(record["scope"], "account")
|
||||
self.assertFalse(record["hasLocalStats"])
|
||||
|
||||
def test_unauthenticated_record_is_not_ready(self) -> None:
|
||||
record = m.build_record(
|
||||
limits=[],
|
||||
tier_label="",
|
||||
usage_status_text="",
|
||||
auth_help_text="Run `grok login` to restore SuperGrok usage.",
|
||||
now_iso="2026-08-20T00:00:00+00:00",
|
||||
)
|
||||
self.assertFalse(record["ready"])
|
||||
self.assertEqual(record["limits"], [])
|
||||
|
||||
|
||||
class TestWritePath(unittest.TestCase):
|
||||
def test_skips_write_when_official_present(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
usage_dir = Path(tmp) / "usage"
|
||||
usage_dir.mkdir()
|
||||
existing = usage_dir / "grok.json"
|
||||
existing.write_text('{"id":"grok","from":"official"}\n')
|
||||
result = m.maybe_write_record(
|
||||
record={"id": "grok", "from": "household"},
|
||||
usage_dir=usage_dir,
|
||||
yield_to_official=True,
|
||||
)
|
||||
self.assertEqual(result.action, "yielded")
|
||||
self.assertEqual(existing.read_text(), '{"id":"grok","from":"official"}\n')
|
||||
|
||||
def test_writes_atomically_when_no_official(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
usage_dir = Path(tmp) / "usage"
|
||||
result = m.maybe_write_record(
|
||||
record={"id": "grok", "schemaVersion": 1},
|
||||
usage_dir=usage_dir,
|
||||
yield_to_official=False,
|
||||
)
|
||||
self.assertEqual(result.action, "wrote")
|
||||
written = json.loads((usage_dir / "grok.json").read_text())
|
||||
self.assertEqual(written["id"], "grok")
|
||||
|
||||
def test_https_only_client_rejects_http(self) -> None:
|
||||
with self.assertRaises(m.HttpError):
|
||||
m.https_get("http://example.com/billing", headers={})
|
||||
|
||||
|
||||
class TestHttpsClientRedirects(unittest.TestCase):
|
||||
def test_redirects_are_refused(self) -> None:
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self) -> bytes:
|
||||
return b"{}"
|
||||
|
||||
def geturl(self) -> str:
|
||||
return "https://evil.example/steal"
|
||||
|
||||
def fake_open(_request, timeout=10):
|
||||
return FakeResponse()
|
||||
|
||||
with mock.patch.object(m, "_urlopen", fake_open):
|
||||
with self.assertRaises(m.HttpError) as ctx:
|
||||
m.https_get(
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits",
|
||||
headers={"Authorization": "Bearer " + FAKE_TOKEN},
|
||||
)
|
||||
self.assertIn("redirect", str(ctx.exception).lower())
|
||||
|
||||
|
||||
class TestCollect(unittest.TestCase):
|
||||
def test_collect_record_does_not_embed_token(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
grok = Path(tmp) / ".grok"
|
||||
grok.mkdir()
|
||||
(grok / "auth.json").write_text(
|
||||
json.dumps({"https://accounts.x.ai/sign-in": {"key": FAKE_TOKEN}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_get(url: str, headers):
|
||||
auth = headers.get("Authorization", "")
|
||||
self.assertTrue(auth.startswith("Bearer "))
|
||||
if "billing" in url:
|
||||
return {
|
||||
"config": {
|
||||
"creditUsagePercent": 12,
|
||||
"currentPeriod": {"end": "2026-08-27T00:00:00Z"},
|
||||
}
|
||||
}
|
||||
if "settings" in url:
|
||||
return {"subscription_tier_display": "SuperGrok"}
|
||||
raise AssertionError(url)
|
||||
|
||||
record = m.collect_record(
|
||||
env={"GROK_HOME": str(grok)},
|
||||
home=Path(tmp),
|
||||
http_get=fake_get,
|
||||
)
|
||||
blob = json.dumps(record)
|
||||
self.assertNotIn(FAKE_TOKEN, blob)
|
||||
self.assertEqual(record["limits"][0]["percent"], 12)
|
||||
self.assertEqual(record["tierLabel"], "SuperGrok")
|
||||
self.assertTrue(record["ready"])
|
||||
|
||||
def test_collect_record_without_auth_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
record = m.collect_record(env={"GROK_HOME": tmp}, home=Path(tmp))
|
||||
self.assertFalse(record["ready"])
|
||||
self.assertIn("grok login", record["authHelpText"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
16
uninstall.sh
Executable file
16
uninstall.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
# Remove the household Grok usage collector for the current user.
|
||||
# Leaves grok.json in place so a later official collector can overwrite it.
|
||||
set -euo pipefail
|
||||
|
||||
bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
||||
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
|
||||
systemctl --user disable --now omarchy-grok-usage.timer 2>/dev/null || true
|
||||
systemctl --user disable --now omarchy-grok-usage.service 2>/dev/null || true
|
||||
rm -f "$unit_dir/omarchy-grok-usage.timer" "$unit_dir/omarchy-grok-usage.service"
|
||||
rm -f "$bin_dir/omarchy-grok-usage"
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
|
||||
echo "Removed omarchy-grok-usage timer and ~/.local/bin/omarchy-grok-usage"
|
||||
echo "Did not delete ~/.local/state/omarchy/agents/usage/grok.json"
|
||||
Loading…
Add table
Reference in a new issue