diff --git a/bin/omarchy-agent-usage-cursor b/bin/omarchy-agent-usage-cursor new file mode 100755 index 00000000000..9d328032155 --- /dev/null +++ b/bin/omarchy-agent-usage-cursor @@ -0,0 +1,671 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the Cursor usage record as JSON +# omarchy:args=[--force] [--limits-only] [--state-db ] [--auth-json ] +# omarchy:hidden=true +"""Collect Cursor plan usage into one display-ready JSON record. + +Credentials come from Cursor's local state.vscdb (IDE, read-only) or, when that +is missing, from the Cursor Agent CLI auth file (~/.config/cursor/auth.json). + +Limits come from GetCurrentPeriodUsage. Tier comes from GetPlanInfo (falling +back to membership fields on the credentials). Day / model / prompt stats come +from GetFilteredUsageEvents (paged) plus GetAggregatedUsageEvents for the +all-time model breakdown when events are sparse. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sqlite3 +import sys +import urllib.error +import urllib.request +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +AGENT_ID = "cursor" +AGENT_NAME = "Cursor" +AUTH_HELP = "Open Cursor and sign in, or run `agent login`." +API_BASE = "https://api2.cursor.sh/aiserver.v1.DashboardService" +PERIOD_PATH = "GetCurrentPeriodUsage" +PLAN_PATH = "GetPlanInfo" +EVENTS_PATH = "GetFilteredUsageEvents" +AGGREGATED_PATH = "GetAggregatedUsageEvents" +DEFAULT_STATE_DB = Path.home() / ".config" / "Cursor" / "User" / "globalStorage" / "state.vscdb" +DEFAULT_AUTH_JSON = Path.home() / ".config" / "cursor" / "auth.json" +EVENTS_PAGE_SIZE = 200 +EVENTS_MAX_PAGES = 40 +STATS_KEYS = ( + "todayPrompts", + "todaySessions", + "todayTotalTokens", + "todayTokensByModel", + "recentDays", + "totalPrompts", + "totalSessions", + "activeDays", + "activeDates", + "modelUsage", + "hasLocalStats", + "hasPromptStats", +) + + +def usage_record_path() -> Path: + root = Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local" / "state")) + return root / "omarchy" / "agents" / "usage" / f"{AGENT_ID}.json" + + +def load_cached_stats() -> dict[str, Any]: + path = usage_record_path() + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + if not isinstance(data, dict): + return {} + cached = {key: data[key] for key in STATS_KEYS if key in data} + if cached.get("hasLocalStats") is None and ( + number(cached.get("totalPrompts")) > 0 + or bool(cached.get("modelUsage")) + or any(number((day or {}).get("messageCount")) > 0 for day in (cached.get("recentDays") or [])) + ): + cached["hasLocalStats"] = True + return cached + + +def empty_stats() -> dict[str, Any]: + return { + "todayPrompts": 0, + "todaySessions": 0, + "todayTotalTokens": 0, + "todayTokensByModel": {}, + "recentDays": [], + "totalPrompts": 0, + "totalSessions": 0, + "activeDays": 0, + "activeDates": [], + "modelUsage": {}, + } + + +def empty_bucket() -> dict[str, int]: + return { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + } + + +def empty_result(**overrides: Any) -> dict[str, Any]: + out: dict[str, Any] = { + "schemaVersion": 1, + "id": AGENT_ID, + "name": AGENT_NAME, + "updatedAt": datetime.now(timezone.utc).isoformat(), + "ready": False, + "hasLocalStats": False, + "hasPromptStats": True, + "tierLabel": "", + "usageStatusText": "", + "authHelpText": "", + "limits": [], + } + out.update(empty_stats()) + out.update(overrides) + return out + + +def expand_path(value: str | None, fallback: Path) -> Path: + text = str(value or "").strip() + if not text: + return fallback + return Path(os.path.expandvars(os.path.expanduser(text))).expanduser() + + +def read_item(conn: sqlite3.Connection, key: str) -> str | None: + row = conn.execute( + "SELECT value FROM ItemTable WHERE key = ? LIMIT 1", + (key,), + ).fetchone() + if not row or row[0] is None: + return None + value = row[0] + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + text = str(value).strip() + return text or None + + +def load_credentials_from_state_db(state_db: Path) -> tuple[dict[str, str | None] | None, dict[str, Any] | None]: + if not state_db.is_file(): + return None, None + + try: + # Open read-only. mode=ro blocks writes through this connection; WAL/shm + # sidecars may still exist from Cursor's writer. Avoid immutable=1 so a + # live DB with an active WAL remains readable. + uri = state_db.resolve().as_uri() + "?mode=ro" + conn = sqlite3.connect(uri, uri=True, timeout=2) + except sqlite3.Error as exc: + return None, empty_result( + usageStatusText="Cursor unavailable", + authHelpText=f"Could not open Cursor database: {exc}", + ) + + try: + conn.execute("PRAGMA query_only = ON") + access_token = read_item(conn, "cursorAuth/accessToken") + membership = read_item(conn, "cursorAuth/stripeMembershipType") + except sqlite3.Error as exc: + return None, empty_result( + usageStatusText="Cursor unavailable", + authHelpText=f"Could not read Cursor database: {exc}", + ) + finally: + conn.close() + + if not access_token: + return None, None + + return {"accessToken": access_token, "membershipType": membership}, None + + +def load_credentials_from_auth_json(auth_json: Path) -> tuple[dict[str, str | None] | None, dict[str, Any] | None]: + if not auth_json.is_file(): + return None, None + + try: + payload = json.loads(auth_json.read_text(encoding="utf-8")) + except Exception as exc: + return None, empty_result( + usageStatusText="Cursor unavailable", + authHelpText=f"Could not read Cursor auth file: {exc}", + ) + + if not isinstance(payload, dict): + return None, empty_result( + usageStatusText="Cursor unavailable", + authHelpText="Cursor auth file was not a JSON object.", + ) + + access_token = str(payload.get("accessToken") or payload.get("token") or "").strip() + if not access_token: + return None, None + + membership = str( + payload.get("membershipType") + or payload.get("stripeMembershipType") + or payload.get("subscriptionTier") + or "" + ).strip() or None + + return {"accessToken": access_token, "membershipType": membership}, None + + +def load_credentials( + state_db: Path, + auth_json: Path, +) -> tuple[dict[str, str | None] | None, dict[str, Any] | None]: + credentials, error = load_credentials_from_state_db(state_db) + if error is not None: + return None, error + if credentials is not None: + return credentials, None + + credentials, error = load_credentials_from_auth_json(auth_json) + if error is not None: + return None, error + if credentials is not None: + return credentials, None + + if not state_db.is_file() and not auth_json.is_file(): + return None, empty_result( + usageStatusText="Cursor unavailable", + authHelpText="Cursor state database not found. Open Cursor and sign in, or run `agent login`.", + ) + + return None, empty_result( + usageStatusText="Sign in to Cursor", + authHelpText=AUTH_HELP, + ) + + +def to_epoch_ms(value: Any) -> int | None: + if value is None or value == "": + return None + if isinstance(value, (int, float)): + number = float(value) + if not math.isfinite(number): + return None + # Values at/above ~1e11 are already milliseconds (ms since ~1973). + # Smaller magnitudes are treated as seconds. + if abs(number) >= 1e11: + return int(number) + return int(number * 1000.0) + + text = str(value).strip() + if not text: + return None + if text.isdigit() or (text.startswith("-") and text[1:].isdigit()): + return to_epoch_ms(float(text)) + + try: + if text.endswith("Z"): + dt = datetime.fromisoformat(text[:-1] + "+00:00") + else: + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except Exception: + return None + + +def parse_billing_cycle_end(value: Any) -> str: + # Keep resetsAt empty on unparseable/non-finite/out-of-range input so QML + # date parsing stays valid and the collector still emits JSON. + try: + ms = to_epoch_ms(value) + if ms is None: + return "" + return datetime.fromtimestamp(ms / 1000.0, timezone.utc).isoformat() + except (OverflowError, OSError, ValueError, TypeError): + return "" + + +def format_tier(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return " ".join(part.capitalize() for part in text.replace("_", " ").split()) + + +def percent_to_fraction(value: Any) -> float: + if value is None: + return -1.0 + try: + number = float(value) + except (TypeError, ValueError): + return -1.0 + # Non-finite values would serialize as NaN/Infinity and break QML JSON.parse. + if not math.isfinite(number): + return -1.0 + return number / 100.0 + + +def number(value: Any) -> int: + try: + n = float(value or 0) + return round(n) if n == n else 0 + except Exception: + return 0 + + +def date_string(value: date) -> str: + return value.strftime("%Y-%m-%d") + + +def recent_date_strings() -> list[str]: + today = datetime.now().astimezone().date() + return [date_string(today - timedelta(days=offset)) for offset in range(6, -1, -1)] + + +def local_date_from_epoch_ms(ms: int) -> str: + try: + return date_string(datetime.fromtimestamp(ms / 1000.0).astimezone().date()) + except Exception: + return date_string(datetime.now().astimezone().date()) + + +def model_label(raw: Any) -> str: + text = str(raw or "").strip() or "unknown" + if text in ("default", "auto"): + return "Auto" + return text + + +def api_post( + access_token: str, + path: str, + body: dict[str, Any] | None = None, + timeout: float = 30, +) -> tuple[Any | None, dict[str, Any] | None]: + request = urllib.request.Request( + f"{API_BASE}/{path}", + data=json.dumps(body or {}).encode("utf-8"), + method="POST", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8", errors="replace") + status = getattr(response, "status", 200) + except urllib.error.HTTPError as exc: + status = exc.code + raw = exc.read().decode("utf-8", errors="replace") + if status in (401, 403): + return None, empty_result( + usageStatusText="Sign in to Cursor", + authHelpText="Cursor session expired. Open Cursor and sign in again, or run `agent login`.", + ) + return None, empty_result( + usageStatusText="Cursor limits unavailable", + authHelpText=f"Usage API returned HTTP {status}", + ) + except Exception as exc: + return None, empty_result( + usageStatusText="Cursor limits unavailable", + authHelpText=str(exc), + ) + + if status < 200 or status >= 300: + return None, empty_result( + usageStatusText="Cursor limits unavailable", + authHelpText=f"Usage API returned HTTP {status}", + ) + + try: + return json.loads(raw), None + except Exception: + return None, empty_result( + usageStatusText="Cursor limits unavailable", + authHelpText="Could not parse usage response.", + ) + + +def api_post_optional(access_token: str, path: str, body: dict[str, Any] | None = None) -> Any | None: + payload, error = api_post(access_token, path, body, timeout=25) + if error is not None: + # Auth failures are still fatal for the whole collect; bubble them up by + # returning the error dict under a sentinel the caller can detect. + if error.get("usageStatusText") == "Sign in to Cursor": + return error + return None + return payload + + +def fetch_period_usage(access_token: str): + return api_post(access_token, PERIOD_PATH, {}, timeout=20) + + +def fetch_plan_tier(access_token: str, fallback: str | None) -> str: + payload = api_post_optional(access_token, PLAN_PATH, {}) + if isinstance(payload, dict) and payload.get("usageStatusText") == "Sign in to Cursor": + return format_tier(fallback) + if isinstance(payload, dict): + plan = payload.get("planInfo") + if isinstance(plan, dict): + name = str(plan.get("planName") or "").strip() + if name: + return format_tier(name) + return format_tier(fallback) + + +def build_rate_limits(payload: Any, tier_label: str) -> dict[str, Any]: + if not isinstance(payload, dict): + return empty_result( + usageStatusText="Cursor limits unavailable", + authHelpText="Usage response was not a JSON object.", + tierLabel=format_tier(tier_label), + ) + + plan = payload.get("planUsage") + if not isinstance(plan, dict): + return empty_result( + usageStatusText="Cursor limits unavailable", + authHelpText="Usage response did not include plan usage.", + tierLabel=format_tier(tier_label), + ) + + reset_at = parse_billing_cycle_end(payload.get("billingCycleEnd")) + membership = format_tier(tier_label) or format_tier(payload.get("membershipType")) + total_percent = percent_to_fraction(plan.get("totalPercentUsed")) + auto_percent = percent_to_fraction(plan.get("autoPercentUsed")) + api_percent = percent_to_fraction(plan.get("apiPercentUsed")) + # Treat a missing pool as 0% used when the plan object itself is present, + # so a signed-in Cursor account still surfaces in the bar. + if auto_percent < 0 and plan.get("autoPercentUsed") is None: + auto_percent = 0.0 + if api_percent < 0 and plan.get("apiPercentUsed") is None: + api_percent = 0.0 + + limits: list[dict[str, Any]] = [] + if total_percent >= 0: + limits.append({"label": "Included total", "percent": total_percent, "resetsAt": reset_at}) + if auto_percent >= 0: + limits.append({"label": "Cursor Models", "percent": auto_percent, "resetsAt": reset_at}) + if api_percent >= 0: + limits.append({"label": "Other Models", "percent": api_percent, "resetsAt": reset_at}) + + return empty_result( + ready=len(limits) > 0, + limits=limits, + tierLabel=membership, + ) + + +def event_token_usage(event: dict[str, Any]) -> dict[str, int]: + usage = event.get("tokenUsage") if isinstance(event.get("tokenUsage"), dict) else {} + bucket = empty_bucket() + bucket["inputTokens"] = number(usage.get("inputTokens")) + bucket["outputTokens"] = number(usage.get("outputTokens")) + bucket["cacheReadInputTokens"] = number(usage.get("cacheReadTokens") or usage.get("cacheReadInputTokens")) + bucket["cacheCreationInputTokens"] = number( + usage.get("cacheWriteTokens") or usage.get("cacheCreationInputTokens") + ) + return bucket + + +def token_total(bucket: dict[str, int]) -> int: + return ( + bucket["inputTokens"] + + bucket["outputTokens"] + + bucket["cacheReadInputTokens"] + + bucket["cacheCreationInputTokens"] + ) + + +def add_bucket(dst: dict[str, int], src: dict[str, int]) -> None: + for key in empty_bucket(): + dst[key] = number(dst.get(key)) + number(src.get(key)) + + +def fetch_all_usage_events(access_token: str) -> tuple[list[dict[str, Any]], int, dict[str, Any] | None]: + events: list[dict[str, Any]] = [] + total = 0 + for page in range(1, EVENTS_MAX_PAGES + 1): + payload = api_post_optional( + access_token, + EVENTS_PATH, + {"page": page, "pageSize": EVENTS_PAGE_SIZE}, + ) + if isinstance(payload, dict) and payload.get("usageStatusText") == "Sign in to Cursor": + return [], 0, payload + if not isinstance(payload, dict): + break + total = max(total, number(payload.get("totalUsageEventsCount"))) + batch = payload.get("usageEventsDisplay") + if not isinstance(batch, list) or not batch: + break + for entry in batch: + if isinstance(entry, dict): + events.append(entry) + if len(batch) < EVENTS_PAGE_SIZE: + break + return events, total or len(events), None + + +def model_usage_from_aggregations(access_token: str) -> dict[str, dict[str, int]]: + payload = api_post_optional(access_token, AGGREGATED_PATH, {}) + if not isinstance(payload, dict) or payload.get("usageStatusText") == "Sign in to Cursor": + return {} + usage: dict[str, dict[str, int]] = {} + for row in payload.get("aggregations") or []: + if not isinstance(row, dict): + continue + label = model_label(row.get("modelIntent") or row.get("model")) + bucket = usage.setdefault(label, empty_bucket()) + bucket["inputTokens"] += number(row.get("inputTokens")) + bucket["outputTokens"] += number(row.get("outputTokens")) + bucket["cacheReadInputTokens"] += number(row.get("cacheReadTokens") or row.get("cacheReadInputTokens")) + bucket["cacheCreationInputTokens"] += number( + row.get("cacheWriteTokens") or row.get("cacheCreationInputTokens") + ) + return usage + + +def build_stats_from_events( + events: list[dict[str, Any]], + reported_total: int, + aggregated_models: dict[str, dict[str, int]], +) -> dict[str, Any]: + today = date_string(datetime.now().astimezone().date()) + recent_dates = recent_date_strings() + recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} + + model_usage: dict[str, dict[str, int]] = {} + today_tokens: dict[str, int] = {} + active_dates: set[str] = set() + conversations: set[str] = set() + today_conversations: set[str] = set() + today_prompts = 0 + today_total = 0 + + for event in events: + ms = to_epoch_ms(event.get("timestamp")) + if ms is None: + continue + day = local_date_from_epoch_ms(ms) + bucket = event_token_usage(event) + total = token_total(bucket) + label = model_label(event.get("model") or event.get("modelIntent")) + + if total > 0: + active_dates.add(day) + add_bucket(model_usage.setdefault(label, empty_bucket()), bucket) + if day in recent: + recent[day]["messageCount"] += total + + conversation = str(event.get("conversationId") or "").strip() + if conversation and conversation != "null": + conversations.add(conversation) + + if day == today: + today_prompts += 1 + today_total += total + if total > 0: + today_tokens[label] = number(today_tokens.get(label)) + total + if conversation and conversation != "null": + today_conversations.add(conversation) + + # Events carry real model ids; aggregations often collapse to "default". + # Only fall back to aggregations when the event scan produced nothing. + if not model_usage and aggregated_models: + model_usage = { + name: dict(bucket) + for name, bucket in aggregated_models.items() + if token_total(bucket) > 0 + } + + stats = empty_stats() + stats.update( + { + "todayPrompts": today_prompts, + "todaySessions": len(today_conversations), + "todayTotalTokens": today_total, + "todayTokensByModel": today_tokens, + "recentDays": [recent[day] for day in recent_dates], + "totalPrompts": max(reported_total, len(events)), + "totalSessions": len(conversations), + "activeDays": len(active_dates), + "activeDates": sorted(active_dates), + "modelUsage": model_usage, + } + ) + return stats + + +def collect_record(access_token: str, membership: str | None, limits_only: bool) -> dict[str, Any]: + period, error = fetch_period_usage(access_token) + if error is not None: + return error + + tier = fetch_plan_tier(access_token, membership) + record = build_rate_limits(period, tier) + if record.get("usageStatusText"): + return record + + # Opening the panel refreshes with --limits-only. Keep the last day/model + # scan so meters update without wiping the charts Claude/Codex keep. + if limits_only: + cached = load_cached_stats() + if cached: + record.update(cached) + record["ready"] = bool(record.get("ready")) or bool(cached.get("hasLocalStats")) + return record + + events, reported_total, auth_error = fetch_all_usage_events(access_token) + if auth_error is not None: + return auth_error + + aggregated = model_usage_from_aggregations(access_token) + stats = build_stats_from_events(events, reported_total, aggregated) + has_stats = ( + number(stats.get("totalPrompts")) > 0 + or number(stats.get("todayTotalTokens")) > 0 + or bool(stats.get("modelUsage")) + or any(number(day.get("messageCount")) > 0 for day in (stats.get("recentDays") or [])) + ) + record["hasLocalStats"] = has_stats + record["ready"] = bool(record.get("ready")) or has_stats + record.update(stats) + return record + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Print the Cursor usage record as JSON") + # --force / --limits-only exist so every collector accepts the same invocation. + parser.add_argument("--force", action="store_true") + parser.add_argument("--limits-only", action="store_true") + parser.add_argument( + "--state-db", + default=os.environ.get("CURSOR_STATE_DB", str(DEFAULT_STATE_DB)), + help="Path to Cursor state.vscdb (read-only)", + ) + parser.add_argument( + "--auth-json", + default=os.environ.get("CURSOR_AUTH_JSON", str(DEFAULT_AUTH_JSON)), + help="Path to Cursor Agent CLI auth.json (fallback when state.vscdb is missing)", + ) + args = parser.parse_args(argv) + + state_db = expand_path(args.state_db, DEFAULT_STATE_DB) + auth_json = expand_path(args.auth_json, DEFAULT_AUTH_JSON) + credentials, error = load_credentials(state_db, auth_json) + if error is not None: + print(json.dumps(error, separators=(",", ":"))) + return 0 + + record = collect_record( + credentials["accessToken"], + credentials.get("membershipType"), + args.limits_only, + ) + print(json.dumps(record, separators=(",", ":"), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 6637531a380..52334b2494b 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -174,6 +174,40 @@ Panel { return text } + // Cursor's two pools share one billing-cycle end; Claude's session vs weekly + // windows usually do not. Only hoist the countdown when every meter agrees + // (or there is only one), otherwise leave resets on each row. + function limitsShareReset(windows) { + if (!windows || windows.length === 0) return false + if (windows.length === 1) return String(windows[0].resetAt || "") !== "" + var shared = String(windows[0].resetAt || "") + if (shared === "") return false + for (var i = 1; i < windows.length; i++) { + if (String(windows[i].resetAt || "") !== shared) return false + } + return true + } + + function sharedLimitsResetText(windows) { + if (!limitsShareReset(windows)) return "" + var remainingMs = resetMsFor(windows[0]) + return remainingMs > 0 ? "Resets in " + formatDuration(remainingMs) : "" + } + + readonly property bool limitsResetInHeader: limitsShareReset(limits) + readonly property string limitsHeaderReset: sharedLimitsResetText(limits) + + // Claude/Codex keep their day/model sections whenever the collector supplies + // rows (including an all-zero week of synthesized days). Meters-only agents + // (hasLocalStats false) stay out even under syncMode's seven-row padding. + readonly property bool hasDayChart: !!provider + && provider.hasLocalStats !== false + && Array.isArray(provider.recentDays) + && provider.recentDays.length > 0 + readonly property bool hasModelChart: !!provider + && provider.hasLocalStats !== false + && models.length > 0 + // ---------------------------------------------------------------- content // The plan you pay for, under the name of the tool it pays for. Limits live @@ -590,10 +624,34 @@ Panel { width: parent.width spacing: Style.space(10) - PanelSectionHeader { - text: "LIMITS" - foreground: root.foreground - fontFamily: root.fontFamily + Item { + width: parent.width + implicitHeight: Math.max(limitsHeaderLabel.implicitHeight, limitsHeaderResetText.implicitHeight) + + PanelSectionHeader { + id: limitsHeaderLabel + text: "LIMITS" + foreground: root.foreground + fontFamily: root.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: limitsHeaderResetText + visible: text !== "" + width: Math.max(0, parent.width - limitsHeaderLabel.width - Style.spacing.md) + text: root.limitsHeaderReset + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + horizontalAlignment: Text.AlignRight + elide: Text.ElideLeft + anchors.right: parent.right + // Match the header glyphs, not the padded box centre. + anchors.verticalCenter: limitsHeaderLabel.verticalCenter + anchors.verticalCenterOffset: Math.round(limitsHeaderLabel.topPadding / 2) + } } Repeater { @@ -603,6 +661,7 @@ Panel { required property var modelData width: limitsSection.width window: modelData + showReset: !root.limitsResetInHeader } } } @@ -615,7 +674,7 @@ Panel { Column { id: usageSection - visible: !!root.provider && root.provider.recentDays && root.provider.recentDays.length > 0 + visible: root.hasDayChart width: parent.width spacing: Style.spacing.md @@ -654,7 +713,7 @@ Panel { Column { id: modelSection - visible: root.models.length > 0 + visible: root.hasModelChart width: parent.width spacing: Style.spacing.md @@ -699,6 +758,7 @@ Panel { component LimitRow: Column { id: limitRow property var window: null + property bool showReset: true readonly property bool alarming: window && window.percent >= 0.9 @@ -744,8 +804,10 @@ Panel { Text { id: resetText + visible: limitRow.showReset && text !== "" width: parent.width text: { + if (!limitRow.showReset) return "" var remainingMs = root.resetMsFor(limitRow.window) return remainingMs > 0 ? "Resets in " + root.formatDuration(remainingMs) : "" } diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 51ad4e43c2d..bc5d3d53ba8 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -14,16 +14,21 @@ cross-device aggregation); `Agent.qml` is the per-record file watcher. - **Subscription switch** — one chip per enabled agent (`h`/`l` or click). It appears only when more than one agent is enabled. - **Limits** — the percentage of each allowance used, a matching meter, and - the time until the session or weekly window resets. + the time until the session or weekly window resets. When every meter shares + one reset time (Cursor pools), that countdown is hoisted into the LIMITS + header. - **Balance** — prepaid agents report a credit ledger instead of limits: remaining credit, a fuel-gauge meter that drains toward empty, and funded-versus-spent detail. - **Tokens by day** — one row per day for the last week: day, bar, tokens, with today bolded at the bottom. Hover today for its prompt and session count. + Shown only when the agent has local day history (`hasLocalStats` true — Claude, + Codex, Fireworks, and any collector that emits `recentDays`). - **Tokens by model** — tokens per model with the bar behind each row scaled to the heaviest model, the same way the weekly chart scales to its busiest day. Hover for the - input / output / cache split. + input / output / cache split. Shown only when the agent has model totals + (`hasLocalStats` true). A subscription appears only when it is enabled in settings and has actually recorded usage — on this machine or on a synced one. With one such agent @@ -45,7 +50,7 @@ on its refresh timer and whenever you ask for a refresh, and picks up any record that lands in the directory regardless of who wrote it. Adding an agent therefore never touches this plugin: ship a collector that -prints the record contract (see the `claude` and `codex` collectors in +prints the record contract (see the `claude`, `codex`, and `cursor` collectors in `bin/`), and the panel gains a tab. An `assets/.svg` mark is optional — with an `assets/-light.svg` twin if the mark needs a dark variant for light surfaces — and the bar glyph stands in when there is none. @@ -54,11 +59,16 @@ 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) | +| `cursor` | Cursor `state.vscdb` or Agent CLI `auth.json` + `GetCurrentPeriodUsage` | `GetFilteredUsageEvents` (day / prompts / sessions) and `GetAggregatedUsageEvents` (model totals); tier from `GetPlanInfo` | | `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days | 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`. Cursor needs a signed-in Cursor +IDE or Agent CLI so the collector can read a local session token +(`CURSOR_STATE_DB` / `CURSOR_AUTH_JSON` override the paths). Day and model +charts come from Cursor's usage dashboard APIs, not local transcript files. +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 @@ -128,6 +138,7 @@ edit `shell.json` directly): omarchy bar set omarchy.agents providers '{ "claude": { "enabled": true }, "codex": { "enabled": false }, + "cursor": { "enabled": true }, "fireworks": { "enabled": true } }' --json ``` diff --git a/shell/plugins/agents/assets/cursor-light.svg b/shell/plugins/agents/assets/cursor-light.svg new file mode 100644 index 00000000000..80081ae2e08 --- /dev/null +++ b/shell/plugins/agents/assets/cursor-light.svg @@ -0,0 +1 @@ +Cursor diff --git a/shell/plugins/agents/assets/cursor.svg b/shell/plugins/agents/assets/cursor.svg new file mode 100644 index 00000000000..ab73c3ce34c --- /dev/null +++ b/shell/plugins/agents/assets/cursor.svg @@ -0,0 +1 @@ +Cursor diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json index 1743a798214..dfc24a1e26a 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, Cursor, 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 }, + "cursor": { "enabled": true }, "fireworks": { "enabled": true } }, "refreshIntervalSec": 900, diff --git a/test/shell.d/agent-usage-cursor-scanner-test.sh b/test/shell.d/agent-usage-cursor-scanner-test.sh new file mode 100644 index 00000000000..08c750b71ef --- /dev/null +++ b/test/shell.d/agent-usage-cursor-scanner-test.sh @@ -0,0 +1,373 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +require_command jq +require_command python3 + +TEST_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME"' EXIT + +COLLECTOR="$ROOT/bin/omarchy-agent-usage-cursor" + +make_state_db() { + local path="$1" + shift + python3 - "$path" "$@" <<'PY' +import sqlite3 +import sys + +path = sys.argv[1] +pairs = list(zip(sys.argv[2::2], sys.argv[3::2])) +conn = sqlite3.connect(path) +conn.execute("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)") +for key, value in pairs: + conn.execute("INSERT INTO ItemTable(key, value) VALUES (?, ?)", (key, value)) +conn.commit() +conn.close() +PY +} + +missing=$(HOME="$TEST_HOME" python3 "$COLLECTOR" \ + --state-db "$TEST_HOME/missing-state.vscdb") + +[[ $(jq -r '.usageStatusText' <<<"$missing") == "Cursor unavailable" ]] || + fail "Cursor collector reports missing state database" "$missing" +pass "Cursor collector reports missing state database" + +[[ $(jq -r '.authHelpText' <<<"$missing") == *"not found"* ]] || + fail "Cursor collector explains missing state database" "$missing" +pass "Cursor collector explains missing state database" + +[[ $(jq -r '.id + "/" + .name' <<<"$missing") == "cursor/Cursor" ]] || + fail "Cursor collector identifies itself without credentials" "$missing" +pass "Cursor collector identifies itself without credentials" + +no_token_db="$TEST_HOME/no-token.vscdb" +make_state_db "$no_token_db" \ + "cursorAuth/stripeMembershipType" "pro" + +no_token=$(HOME="$TEST_HOME" python3 "$COLLECTOR" \ + --state-db "$no_token_db") + +[[ $(jq -r '.usageStatusText' <<<"$no_token") == "Sign in to Cursor" ]] || + fail "Cursor collector reports missing access token" "$no_token" +pass "Cursor collector reports missing access token" + +[[ $(jq -r '.authHelpText' <<<"$no_token") == *"sign in"* ]] || + fail "Cursor collector explains missing access token" "$no_token" +pass "Cursor collector explains missing access token" + +[[ $(jq -r '.limits | length' <<<"$no_token") == "0" ]] || + fail "Cursor collector keeps meters unset without a token" "$no_token" +pass "Cursor collector keeps meters unset without a token" + +state_db="$TEST_HOME/state.vscdb" +make_state_db "$state_db" \ + "cursorAuth/accessToken" "test-token" \ + "cursorAuth/stripeMembershipType" "pro" + +# Read-only credential load + plan mapping without hitting the network. +mapped=$(python3 - "$COLLECTOR" "$state_db" <<'PY' +import importlib.util +import json +import sys +from importlib.machinery import SourceFileLoader +from pathlib import Path + +scanner_path = Path(sys.argv[1]) +state_db = Path(sys.argv[2]) +loader = SourceFileLoader("omarchy_agent_usage_cursor", str(scanner_path)) +spec = importlib.util.spec_from_loader(loader.name, loader) +mod = importlib.util.module_from_spec(spec) +loader.exec_module(mod) + +credentials, error = mod.load_credentials(state_db, Path("/nonexistent-cursor-auth.json")) +if error is not None: + print(json.dumps(error)) + raise SystemExit(1) + +payload = { + "billingCycleEnd": 1893456000000, + "membershipType": "pro", + "planUsage": { + "autoPercentUsed": 12.5, + "apiPercentUsed": 40, + }, +} +print(json.dumps(mod.build_rate_limits(payload, credentials.get("membershipType")), separators=(",", ":"))) +PY +) + +[[ $(jq -r '.limits[0].label' <<<"$mapped") == "Cursor Models" ]] || + fail "Cursor collector labels auto pool as Cursor Models" "$mapped" +pass "Cursor collector labels auto pool as Cursor Models" + +[[ $(jq -r '.limits[1].label' <<<"$mapped") == "Other Models" ]] || + fail "Cursor collector labels api pool as Other Models" "$mapped" +pass "Cursor collector labels api pool as Other Models" + +[[ $(jq -r '.limits[0].percent' <<<"$mapped") == "0.125" ]] || + fail "Cursor collector converts auto percent to a fraction" "$mapped" +pass "Cursor collector converts auto percent to a fraction" + +[[ $(jq -r '.limits[1].percent' <<<"$mapped") == "0.4" ]] || + fail "Cursor collector converts api percent to a fraction" "$mapped" +pass "Cursor collector converts api percent to a fraction" + +[[ $(jq -r '.tierLabel' <<<"$mapped") == "Pro" ]] || + fail "Cursor collector formats membership tier" "$mapped" +pass "Cursor collector formats membership tier" + +[[ $(jq -r '.limits[0].resetsAt' <<<"$mapped") == $(jq -r '.limits[1].resetsAt' <<<"$mapped") ]] || + fail "Cursor collector shares one billing-cycle reset across pools" "$mapped" +pass "Cursor collector shares one billing-cycle reset across pools" + +[[ $(jq -c '.recentDays' <<<"$mapped") == "[]" && $(jq -c '.modelUsage' <<<"$mapped") == "{}" ]] || + fail "Cursor collector keeps day/model charts empty" "$mapped" +pass "Cursor collector keeps day/model charts empty" + +[[ $(jq -r '.hasLocalStats' <<<"$mapped") == "false" ]] || + fail "Cursor collector reports hasLocalStats false for meters-only" "$mapped" +pass "Cursor collector reports hasLocalStats false for meters-only" + +[[ $(jq -r '.hasLocalStats' <<<"$missing") == "false" && $(jq -r '.hasLocalStats' <<<"$no_token") == "false" ]] || + fail "Cursor collector keeps hasLocalStats false without credentials" "$missing $no_token" +pass "Cursor collector keeps hasLocalStats false without credentials" + +helpers=$(python3 - "$COLLECTOR" <<'PY' +import importlib.util +import json +import sys +from importlib.machinery import SourceFileLoader +from pathlib import Path + +scanner_path = Path(sys.argv[1]) +loader = SourceFileLoader("omarchy_agent_usage_cursor", str(scanner_path)) +spec = importlib.util.spec_from_loader(loader.name, loader) +mod = importlib.util.module_from_spec(spec) +loader.exec_module(mod) + +print(json.dumps({ + "ms": mod.to_epoch_ms(1893456000000), + "seconds": mod.to_epoch_ms(1893456000), + "reset": mod.parse_billing_cycle_end(1893456000000), + "bad": mod.parse_billing_cycle_end("not-a-date"), + "empty": mod.parse_billing_cycle_end(None), + "oor": mod.parse_billing_cycle_end(10 ** 20), + "inf": mod.parse_billing_cycle_end(float("inf")), + "nan": mod.parse_billing_cycle_end(float("nan")), + "pct": mod.percent_to_fraction(12.5), + "pct_inf": mod.percent_to_fraction(float("inf")), + "pct_nan": mod.percent_to_fraction(float("nan")), + "pct_bad": mod.percent_to_fraction("nope"), +})) +PY +) + +[[ $(jq -r '.ms' <<<"$helpers") == "1893456000000" ]] || + fail "Cursor collector keeps millisecond timestamps as milliseconds" "$helpers" +pass "Cursor collector keeps millisecond timestamps as milliseconds" + +[[ $(jq -r '.seconds' <<<"$helpers") == "1893456000000" ]] || + fail "Cursor collector converts second timestamps to milliseconds" "$helpers" +pass "Cursor collector converts second timestamps to milliseconds" + +[[ $(jq -r '.reset' <<<"$helpers") == "2030-01-01T00:00:00+00:00" ]] || + fail "Cursor collector formats parseable billing cycle ends as ISO" "$helpers" +pass "Cursor collector formats parseable billing cycle ends as ISO" + +[[ $(jq -r '.bad' <<<"$helpers") == "" && $(jq -r '.empty' <<<"$helpers") == "" ]] || + fail "Cursor collector clears unparseable billing cycle ends" "$helpers" +pass "Cursor collector clears unparseable billing cycle ends" + +[[ $(jq -r '.oor' <<<"$helpers") == "" ]] || + fail "Cursor collector clears out-of-range billing cycle ends" "$helpers" +pass "Cursor collector clears out-of-range billing cycle ends" + +[[ $(jq -r '.inf' <<<"$helpers") == "" && $(jq -r '.nan' <<<"$helpers") == "" ]] || + fail "Cursor collector clears non-finite billing cycle ends" "$helpers" +pass "Cursor collector clears non-finite billing cycle ends" + +[[ $(jq -e '.pct == 0.125' <<<"$helpers") == "true" ]] || + fail "Cursor collector converts finite percents to fractions" "$helpers" +pass "Cursor collector converts finite percents to fractions" + +[[ $(jq -e '.pct_inf == -1 and .pct_nan == -1 and .pct_bad == -1' <<<"$helpers") == "true" ]] || + fail "Cursor collector rejects non-finite and invalid percents" "$helpers" +pass "Cursor collector rejects non-finite and invalid percents" + +bad_payload=$(python3 - "$COLLECTOR" <<'PY' +import importlib.util +import json +import sys +from importlib.machinery import SourceFileLoader +from pathlib import Path + +scanner_path = Path(sys.argv[1]) +loader = SourceFileLoader("omarchy_agent_usage_cursor", str(scanner_path)) +spec = importlib.util.spec_from_loader(loader.name, loader) +mod = importlib.util.module_from_spec(spec) +loader.exec_module(mod) + +print(json.dumps(mod.build_rate_limits(None, "pro"))) +PY +) + +[[ $(jq -r '.usageStatusText' <<<"$bad_payload") == "Cursor limits unavailable" ]] || + fail "Cursor collector rejects a null usage payload" "$bad_payload" +pass "Cursor collector rejects a null usage payload" + +[[ $(jq -r '.authHelpText' <<<"$bad_payload") == *"JSON object"* ]] || + fail "Cursor collector explains a non-object usage payload" "$bad_payload" +pass "Cursor collector explains a non-object usage payload" + +[[ $(jq -r '.limits | length' <<<"$bad_payload") == "0" ]] || + fail "Cursor collector keeps meters unset for a non-object usage payload" "$bad_payload" +pass "Cursor collector keeps meters unset for a non-object usage payload" + +# Full collector path with a fake successful GetCurrentPeriodUsage response. +http_ok=$(HOME="$TEST_HOME" XDG_STATE_HOME="$TEST_HOME/.local/state" python3 - "$COLLECTOR" "$state_db" <<'PY' +import importlib.util +import io +import json +import sys +from contextlib import redirect_stdout +from importlib.machinery import SourceFileLoader +from pathlib import Path + +scanner_path = Path(sys.argv[1]) +state_db = Path(sys.argv[2]) +loader = SourceFileLoader("omarchy_agent_usage_cursor", str(scanner_path)) +spec = importlib.util.spec_from_loader(loader.name, loader) +mod = importlib.util.module_from_spec(spec) +loader.exec_module(mod) + +payload = { + "billingCycleEnd": 1893456000000, + "membershipType": "pro", + "planUsage": { + "autoPercentUsed": 12.5, + "apiPercentUsed": 40, + }, +} +body = json.dumps(payload).encode("utf-8") + + +class FakeResponse: + def __init__(self, data, status=200): + self._data = data + self.status = status + + def read(self): + return self._data + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +def fake_urlopen(request, timeout=None): + url = request.full_url + assert request.get_header("Authorization") == "Bearer test-token" + if "GetCurrentPeriodUsage" in url: + return FakeResponse(body) + if "GetPlanInfo" in url: + return FakeResponse(json.dumps({"planInfo": {"planName": "Pro"}}).encode("utf-8")) + if "GetFilteredUsageEvents" in url: + return FakeResponse(json.dumps({"totalUsageEventsCount": 0, "usageEventsDisplay": []}).encode("utf-8")) + if "GetAggregatedUsageEvents" in url: + return FakeResponse(json.dumps({"aggregations": []}).encode("utf-8")) + raise AssertionError(f"unexpected url {url}") + + +mod.urllib.request.urlopen = fake_urlopen +buf = io.StringIO() +with redirect_stdout(buf): + code = mod.main(["--state-db", str(state_db)]) +if code != 0: + raise SystemExit(f"main exited {code}") +print(buf.getvalue().strip()) +PY +) + +[[ $(jq -r '.limits[0].label' <<<"$http_ok") == "Cursor Models" ]] || + fail "Cursor collector maps mocked period usage to Cursor Models" "$http_ok" +pass "Cursor collector maps mocked period usage to Cursor Models" + +[[ $(jq -r '.limits[1].label' <<<"$http_ok") == "Other Models" ]] || + fail "Cursor collector maps mocked period usage to Other Models" "$http_ok" +pass "Cursor collector maps mocked period usage to Other Models" + +[[ $(jq -r '.limits[0].percent' <<<"$http_ok") == "0.125" && $(jq -r '.limits[1].percent' <<<"$http_ok") == "0.4" ]] || + fail "Cursor collector maps mocked period usage percents" "$http_ok" +pass "Cursor collector maps mocked period usage percents" + +[[ $(jq -r '.tierLabel' <<<"$http_ok") == "Pro" ]] || + fail "Cursor collector maps mocked period usage tier" "$http_ok" +pass "Cursor collector maps mocked period usage tier" + +[[ $(jq -r '.limits[0].resetsAt' <<<"$http_ok") == "2030-01-01T00:00:00+00:00" ]] || + fail "Cursor collector maps mocked billing cycle end" "$http_ok" +pass "Cursor collector maps mocked billing cycle end" + +[[ $(jq -r '.hasLocalStats' <<<"$http_ok") == "false" ]] || + fail "Cursor collector keeps hasLocalStats false on successful period usage" "$http_ok" +pass "Cursor collector keeps hasLocalStats false on successful period usage" + +[[ $(jq -r '.ready' <<<"$http_ok") == "true" ]] || + fail "Cursor collector marks ready when period meters are present" "$http_ok" +pass "Cursor collector marks ready when period meters are present" + +# Auth failure from GetCurrentPeriodUsage (HTTP 401). +auth_fail=$(python3 - "$COLLECTOR" "$state_db" <<'PY' +import importlib.util +import io +import json +import sys +import urllib.error +from contextlib import redirect_stdout +from importlib.machinery import SourceFileLoader +from pathlib import Path + +scanner_path = Path(sys.argv[1]) +state_db = Path(sys.argv[2]) +loader = SourceFileLoader("omarchy_agent_usage_cursor", str(scanner_path)) +spec = importlib.util.spec_from_loader(loader.name, loader) +mod = importlib.util.module_from_spec(spec) +loader.exec_module(mod) + + +def fake_urlopen(request, timeout=None): + raise urllib.error.HTTPError( + request.full_url, + 401, + "Unauthorized", + hdrs=None, + fp=io.BytesIO(b'{"error":"unauthorized"}'), + ) + + +mod.urllib.request.urlopen = fake_urlopen +buf = io.StringIO() +with redirect_stdout(buf): + code = mod.main(["--state-db", str(state_db)]) +if code != 0: + raise SystemExit(f"main exited {code}") +print(buf.getvalue().strip()) +PY +) + +[[ $(jq -r '.usageStatusText' <<<"$auth_fail") == "Sign in to Cursor" ]] || + fail "Cursor collector reports expired session on HTTP 401" "$auth_fail" +pass "Cursor collector reports expired session on HTTP 401" + +[[ $(jq -r '.authHelpText' <<<"$auth_fail") == *"expired"* ]] || + fail "Cursor collector explains expired session on HTTP 401" "$auth_fail" +pass "Cursor collector explains expired session on HTTP 401" + +[[ $(jq -r '.limits | length' <<<"$auth_fail") == "0" ]] || + fail "Cursor collector keeps meters unset after auth failure" "$auth_fail" +pass "Cursor collector keeps meters unset after auth failure"