diff --git a/bin/omarchy-agent-usage-antigravity b/bin/omarchy-agent-usage-antigravity new file mode 100755 index 00000000000..aa9158669d9 --- /dev/null +++ b/bin/omarchy-agent-usage-antigravity @@ -0,0 +1,273 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the Antigravity usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Collect Antigravity (agy) usage into one display-ready JSON record. + +Local stats (prompts, sessions, active days) come from +~/.gemini/antigravity-cli/history.jsonl and conversation_summaries.db. Rate +limits come from `agy -p "/usage" --output-format json` -- the same call the +CLI's own /usage slash command makes -- so they are the real per-group quota +(Gemini models; Claude and GPT models) rather than a local estimate. The +agents panel only ever reads the JSON this prints. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sqlite3 +import subprocess +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +AGENT_ID = "antigravity" +AGENT_NAME = "Antigravity" +AUTH_HELP = "Run `agy` and sign in, then retry." + +# agy reports remaining_fraction (100% -> 0% as quota depletes). Omarchy's +# convention, set by the Claude and Codex collectors, is percent USED (0% -> +# 100% as quota depletes) -- so this both inverts the fraction and drops +# "Remaining" from the label, which would otherwise contradict the number +# next to it. +WINDOW_LABELS = {"weekly": "Weekly", "5h": "Session (5-hour)"} + + +class AntigravityError(Exception): + pass + + +def data_dir() -> Path: + return Path(os.environ.get("ANTIGRAVITY_DATA_DIR") or "~/.gemini/antigravity-cli").expanduser() + + +def recent_date_strings(today: date) -> list[str]: + return [(today - timedelta(days=offset)).isoformat() for offset in range(6, -1, -1)] + + +def local_date_from_ms(value: Any) -> str: + try: + return datetime.fromtimestamp(float(value) / 1000.0).astimezone().date().isoformat() + except (TypeError, ValueError, OSError, OverflowError): + return datetime.now().astimezone().date().isoformat() + + +def empty_stats() -> dict[str, Any]: + return { + "todayPrompts": 0, + "todaySessions": 0, + "todayTotalTokens": 0, + "todayTokensByModel": {}, + "recentDays": [], + "totalPrompts": 0, + "totalSessions": 0, + "activeDays": 0, + "activeDates": [], + "modelUsage": {}, + } + + +def base_record(**overrides: Any) -> dict[str, Any]: + record: dict[str, Any] = { + "schemaVersion": 1, + "id": AGENT_ID, + "name": AGENT_NAME, + "updatedAt": datetime.now(timezone.utc).isoformat(), + "ready": False, + "hasLocalStats": False, + "tierLabel": "", + "usageStatusText": "", + "authHelpText": "", + "limits": [], + } + record.update(empty_stats()) + record.update(overrides) + return record + + +# ------------------------------------------------------------- local stats + + +def scan_history(path: Path, today: date) -> dict[str, Any]: + recent_dates = recent_date_strings(today) + daily_prompts = {day: 0 for day in recent_dates} + active_dates: set[str] = set() + total_prompts = 0 + + if path.is_file(): + with path.open(encoding="utf-8", errors="replace") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + total_prompts += 1 + day = local_date_from_ms(entry.get("timestamp")) + active_dates.add(day) + if day in daily_prompts: + daily_prompts[day] += 1 + + return { + "todayPrompts": daily_prompts[today.isoformat()], + "recentDays": [{"date": d, "messageCount": daily_prompts[d]} for d in recent_dates], + "totalPrompts": total_prompts, + "activeDates": active_dates, + } + + +def scan_sessions(db_path: Path, today: date) -> dict[str, Any]: + total_sessions = 0 + today_sessions = 0 + active_dates: set[str] = set() + + if db_path.is_file(): + conn = sqlite3.connect(f"file:{db_path}?mode=ro&immutable=1", uri=True, timeout=5) + try: + conn.row_factory = sqlite3.Row + for row in conn.execute("SELECT last_modified_time FROM conversation_summaries"): + total_sessions += 1 + day = local_date_from_ms(row["last_modified_time"]) + active_dates.add(day) + if day == today.isoformat(): + today_sessions += 1 + finally: + conn.close() + + return {"totalSessions": total_sessions, "todaySessions": today_sessions, "activeDates": active_dates} + + +def local_stats(base_dir: Path, today: date | None = None) -> dict[str, Any]: + today = today or datetime.now().astimezone().date() + history = scan_history(base_dir / "history.jsonl", today) + sessions = scan_sessions(base_dir / "conversation_summaries.db", today) + active_dates = history["activeDates"] | sessions["activeDates"] + + stats = empty_stats() + stats.update({ + "todayPrompts": history["todayPrompts"], + "todaySessions": sessions["todaySessions"], + "recentDays": history["recentDays"], + "totalPrompts": history["totalPrompts"], + "totalSessions": sessions["totalSessions"], + "activeDays": len(active_dates), + "activeDates": sorted(active_dates), + }) + return stats + + +# ------------------------------------------------------------------ limits + + +def limits_from_payload(payload: dict[str, Any]) -> list[dict[str, Any]]: + groups = ((payload.get("command") or {}).get("data") or {}).get("groups") + limits = [] + for group in groups or []: + if not isinstance(group, dict): + continue + group_name = str(group.get("name") or "") + for bucket in group.get("buckets") or []: + if not isinstance(bucket, dict): + continue + remaining = bucket.get("remaining_fraction") + if remaining is None: + continue + try: + percent = max(0.0, min(1.0, 1.0 - float(remaining))) + except (TypeError, ValueError): + continue + window = str(bucket.get("window") or "") + window_label = WINDOW_LABELS.get(window, str(bucket.get("name") or window)) + label = f"{group_name} — {window_label}" if group_name else window_label + limits.append({ + "label": label, + "title": label, + "percent": round(percent, 4), + "resetsAt": str(bucket.get("reset_time") or ""), + }) + return limits + + +def run_usage_command(agy_bin: str) -> dict[str, Any]: + try: + proc = subprocess.run( + [agy_bin, "-p", "/usage", "--output-format", "json"], + capture_output=True, text=True, timeout=30, + ) + except OSError as error: + raise AntigravityError(f"Could not run agy: {error}") from error + except subprocess.TimeoutExpired as error: + raise AntigravityError("agy did not answer /usage in time") from error + + if proc.returncode != 0: + raise AntigravityError(AUTH_HELP) + + try: + payload = json.loads(proc.stdout) + except json.JSONDecodeError as error: + raise AntigravityError("agy returned an unreadable /usage response") from error + + if not isinstance(payload, dict): + raise AntigravityError("agy returned an unreadable /usage response") + return payload + + +def collect_limits(agy_bin: str | None) -> dict[str, Any]: + if not agy_bin: + return { + "limits": [], + "usageStatusText": "agy not found", + "authHelpText": "Install the Antigravity CLI: mise use -g agy", + } + + try: + limits = limits_from_payload(run_usage_command(agy_bin)) + except AntigravityError as error: + return {"limits": [], "usageStatusText": "Antigravity limits unavailable", "authHelpText": str(error)} + + if not limits: + return { + "limits": [], + "usageStatusText": "Antigravity limits unavailable", + "authHelpText": "agy returned no quota buckets.", + } + return {"limits": limits, "usageStatusText": "", "authHelpText": ""} + + +# -------------------------------------------------------------------- main + + +def scan(agy_bin: str | None, base_dir: Path) -> dict[str, Any]: + stats = local_stats(base_dir) + limits = collect_limits(agy_bin) + record = base_record( + ready=stats["totalPrompts"] > 0 or len(limits["limits"]) > 0, + hasLocalStats=True, + usageStatusText=limits["usageStatusText"], + authHelpText=limits["authHelpText"], + limits=limits["limits"], + ) + record.update(stats) + return record + + +def main() -> int: + parser = argparse.ArgumentParser(description="Print the Antigravity usage record as JSON") + # Neither flag changes anything the collector does -- there is no cache to + # force past and no faster limits-only path -- but every collector accepts + # the same invocation from omarchy-agent-usage-update. + parser.add_argument("--force", action="store_true") + parser.add_argument("--limits-only", action="store_true") + parser.parse_args() + + print(json.dumps(scan(shutil.which("agy"), data_dir()), separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 51ad4e43c2d..a5c4a6bff61 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -55,10 +55,12 @@ light surfaces — and the bar glyph stands in when there is none. | `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback | | `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode sessions) | | `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days | +| `antigravity` | `agy -p "/usage" --output-format json` — the same call the CLI's own `/usage` slash command makes | `~/.gemini/antigravity-cli/history.jsonl` and `conversation_summaries.db` | Claude limits need a signed-in CLI; without credentials the panel says so and falls back to local stats only. A non-default Claude directory is honored via -`CLAUDE_CONFIG_DIR`, Codex via `CODEX_HOME`. Fireworks reads +`CLAUDE_CONFIG_DIR`, Codex via `CODEX_HOME`, Antigravity via +`ANTIGRAVITY_DATA_DIR`. Fireworks reads `FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then `~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is diff --git a/shell/plugins/agents/assets/antigravity-light.svg b/shell/plugins/agents/assets/antigravity-light.svg new file mode 100644 index 00000000000..02a2dbf1b29 --- /dev/null +++ b/shell/plugins/agents/assets/antigravity-light.svg @@ -0,0 +1 @@ +Antigravity diff --git a/shell/plugins/agents/assets/antigravity.svg b/shell/plugins/agents/assets/antigravity.svg new file mode 100644 index 00000000000..52633caff8c --- /dev/null +++ b/shell/plugins/agents/assets/antigravity.svg @@ -0,0 +1 @@ +Antigravity diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json index 1743a798214..1d97884dcb2 100644 --- a/shell/plugins/agents/manifest.json +++ b/shell/plugins/agents/manifest.json @@ -5,7 +5,7 @@ "version": "1.0.0", "author": "Omarchy", "license": "MIT", - "description": "Claude Code, Codex, and Fireworks usage, limits, and pace in a native Omarchy bar panel.", + "description": "Claude Code, Codex, Antigravity, and Fireworks usage, limits, and pace in a native Omarchy bar panel.", "kinds": ["bar-widget"], "activation": "on-demand", "entryPoints": { @@ -21,6 +21,7 @@ "providers": { "claude": { "enabled": true }, "codex": { "enabled": true }, + "antigravity": { "enabled": true }, "fireworks": { "enabled": true } }, "refreshIntervalSec": 900, diff --git a/test/shell.d/agent-usage-antigravity-scanner-test.sh b/test/shell.d/agent-usage-antigravity-scanner-test.sh new file mode 100755 index 00000000000..9335b1cefb0 --- /dev/null +++ b/test/shell.d/agent-usage-antigravity-scanner-test.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +require_command jq +require_command python3 + +TEST_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME"' EXIT + +DATA_DIR="$TEST_HOME/antigravity-cli" +mkdir -p "$DATA_DIR" "$TEST_HOME/bin" + +# Local-day math must be deterministic regardless of where the suite runs. +export TZ=UTC +today=$(date -u +%F) +yesterday=$(date -u -d 'yesterday' +%F) +today_ms=$(date -u -d "$today 12:00:00" +%s000) +yesterday_ms=$(date -u -d "$yesterday 12:00:00" +%s000) + +cat >"$DATA_DIR/history.jsonl" <"$TEST_HOME/bin/agy" <<'EOF' +#!/bin/bash +cat <<'JSON' +{"command":{"data":{"groups":[ + {"name":"Gemini Models","buckets":[ + {"id":"gemini-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":0.925,"reset_time":"2026-09-03T20:12:05Z"}, + {"id":"gemini-5h","name":"Five Hour Limit Remaining","window":"5h","remaining_fraction":0.995,"reset_time":"2026-08-30T22:15:32Z"} + ]}, + {"name":"Claude and GPT models","buckets":[ + {"id":"3p-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-09-06T17:25:01Z"} + ]} +]}}} +JSON +EOF +chmod +x "$TEST_HOME/bin/agy" + +result=$(HOME="$TEST_HOME" ANTIGRAVITY_DATA_DIR="$DATA_DIR" PATH="$TEST_HOME/bin:$PATH" TZ=UTC \ + "$ROOT/bin/omarchy-agent-usage-antigravity") + +[[ $(jq -c '{id, ready, hasLocalStats, todayPrompts, totalPrompts, todaySessions, totalSessions, activeDays}' <<<"$result") \ + == '{"id":"antigravity","ready":true,"hasLocalStats":true,"todayPrompts":2,"totalPrompts":3,"todaySessions":1,"totalSessions":2,"activeDays":2}' ]] || + fail "Antigravity collector counts prompts and sessions from local files" "$result" +pass "Antigravity collector counts prompts and sessions from local files" + +[[ $(jq -r ".recentDays[-1] | .date == \"$today\" and .messageCount == 2" <<<"$result") == "true" ]] || + fail "Antigravity collector places today's prompts on the last day of the week" "$result" +pass "Antigravity collector places today's prompts on the last day of the week" + +[[ $(jq -c '.limits' <<<"$result") == '[{"label":"Gemini Models — Weekly","title":"Gemini Models — Weekly","percent":0.075,"resetsAt":"2026-09-03T20:12:05Z"},{"label":"Gemini Models — Session (5-hour)","title":"Gemini Models — Session (5-hour)","percent":0.005,"resetsAt":"2026-08-30T22:15:32Z"},{"label":"Claude and GPT models — Weekly","title":"Claude and GPT models — Weekly","percent":0.0,"resetsAt":"2026-09-06T17:25:01Z"}]' ]] || + fail "Antigravity collector inverts remaining_fraction into percent used and drops 'Remaining' from the label" "$result" +pass "Antigravity collector inverts remaining_fraction into percent used and drops 'Remaining' from the label" + +# Without agy on PATH, stats still ship -- the collector shouldn't go dark +# just because the CLI that provides limits is missing. +no_agy=$(HOME="$TEST_HOME" ANTIGRAVITY_DATA_DIR="$DATA_DIR" PATH="/usr/bin:/bin" TZ=UTC \ + "$ROOT/bin/omarchy-agent-usage-antigravity") + +[[ $(jq -c '{ready, limits, totalPrompts} + {authHelpTextSet: (.authHelpText | length > 0)}' <<<"$no_agy") \ + == '{"ready":true,"limits":[],"totalPrompts":3,"authHelpTextSet":true}' ]] || + fail "Antigravity collector keeps local stats when agy is not installed" "$no_agy" +pass "Antigravity collector keeps local stats when agy is not installed" + +# A non-zero exit (e.g. not signed in) must not crash the collector. +cat >"$TEST_HOME/bin/agy" <<'EOF' +#!/bin/bash +echo "not signed in" >&2 +exit 1 +EOF +chmod +x "$TEST_HOME/bin/agy" + +signed_out=$(HOME="$TEST_HOME" ANTIGRAVITY_DATA_DIR="$DATA_DIR" PATH="$TEST_HOME/bin:$PATH" TZ=UTC \ + "$ROOT/bin/omarchy-agent-usage-antigravity") + +[[ $(jq -c '{limits, usageStatusText}' <<<"$signed_out") == '{"limits":[],"usageStatusText":"Antigravity limits unavailable"}' ]] || + fail "Antigravity collector handles a failed agy invocation without crashing" "$signed_out" +pass "Antigravity collector handles a failed agy invocation without crashing" + +# No data directory at all (agy never run on this machine). +empty=$(HOME="$TEST_HOME" ANTIGRAVITY_DATA_DIR="$TEST_HOME/no-such-dir" PATH="/usr/bin:/bin" TZ=UTC \ + "$ROOT/bin/omarchy-agent-usage-antigravity") + +[[ $(jq -c '{ready, totalPrompts, totalSessions, "recentDaysCount": (.recentDays | length)}' <<<"$empty") \ + == '{"ready":false,"totalPrompts":0,"totalSessions":0,"recentDaysCount":7}' ]] || + fail "Antigravity collector prints a full record with no data directory" "$empty" +pass "Antigravity collector prints a full record with no data directory"