Initial commit

This commit is contained in:
Armel van Ravels committed 2026-10-09 15:45:07 +02:00
commit 69605d7e54
27 files changed
+5188

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
/target
/result
.direnv
Generated
+1125
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "ai-usage"
version = "0.1.0"
edition = "2021"
description = "AI usage monitor for Hyprland"
license = "MIT"
[dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
libc = "0.2"
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
zbus = "5"
[profile.release]
lto = "thin"
codegen-units = 1
+68
View File
@@ -0,0 +1,68 @@
# AI Usage Monitor voor Linux
Een achtergrondmonitor voor Claude, Codex en Cursor op Hyprland. De daemon haalt usage op via de bestaande CLI-logins. Waybar leest alleen de lokale cache. Er is geen venster.
De Mac-app in de naastliggende map blijft de bron van de providerregels. Deze Linux-app volgt dezelfde vensters: Claude en Codex tonen het 5-uurs- en weekvenster, Cursor toont **Cursor models**, **Other models** en de resetdatum.
## Wat je ziet
De balk toont resterende percentages, bijvoorbeeld `C 38% X 71% Cu 52%`. Een klik ververst meteen. De tooltip bevat resettijden. Kleuren volgen de resterende ruimte: groen boven 50%, oranje vanaf 20%, rood daaronder. Gegevens ouder dan 30 minuten zijn stale. Een mislukte ronde bewaart de laatste succesvolle meting.
Verversen pauzeert als logind de sessie vergrendelt of het systeem in slaap gaat. Zonder een leesbare sessie blijft verversen uit.
## Instellingen
`$XDG_CONFIG_HOME/ai-usage/config.json` (meestal `~/.config/ai-usage/config.json`):
```json
{
"refreshMinutes": 5,
"enabledProviders": ["claude", "codex", "cursor"],
"executableOverrides": {},
"showUsedPercentage": false
}
```
`refreshMinutes` is 5, 15, 30 of 60. `executableOverrides` kan `claude`, `codex` of `cursor` op een absoluut pad zetten. De cache staat in `$XDG_STATE_HOME/ai-usage/usage.json`.
De daemon zoekt `claude`, `codex` en `cursor-agent` (daarna `agent`) op `PATH`, in `~/.local/bin` en in Cursor-extensies. Zonder Agent CLI leest Cursor alleen `cursorAuth/stripeSubscriptionStatus` uit `~/.config/Cursor/User/globalStorage/state.vscdb`.
De tools gebruiken hun eigen login. Deze app leest geen tokens en logt geen CLI-uitvoer. Cursor draait met een netwerkfilter dat alleen usage-endpoints toestaat. Op Linux mag die CLI `secret-tool lookup` gebruiken om zijn bestaande login te lezen; andere subprocessen worden geblokkeerd.
## Nix
```nix
{
inputs.ai-usage.url = "path:/home/avravels/ai-usage/ai-usage-linux";
outputs = { self, home-manager, ai-usage, ... }: {
# in je home-manager configuratie
};
}
```
```nix
imports = [ inputs.ai-usage.homeManagerModules.default ];
programs.ai-usage = {
enable = true;
package = inputs.ai-usage.packages.x86_64-linux.default;
};
```
Dit voegt het pakket toe, start `systemd --user` service `ai-usage` met de grafische sessie, en zet `custom/ai-usage` vooraan `modules-right` als Waybar al aan staat. Je eigen Nix-configuratie blijft onaangeroerd tot je deze module importeert.
## Handmatig
```sh
nix build
./result/bin/ai-usage daemon
./result/bin/ai-usage status
./result/bin/ai-usage waybar
```
Tests:
```sh
cargo test
```
+7
View File
@@ -0,0 +1,7 @@
{
"rate_limits_available": true,
"rate_limits": {
"five_hour": { "utilization": 62, "resets_at": "2026-10-02T12:22:00.000Z" },
"seven_day": { "utilization": 38, "resets_at": "2026-10-04T07:00:00Z" }
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"rateLimits": { "limitId": "codex", "primary": { "usedPercent": 29, "windowDurationMins": 300, "resetsAt": 1790941620 }, "secondary": null },
"rateLimitsByLimitId": {
"codex": {
"primary": { "usedPercent": 29, "windowDurationMins": 300, "resetsAt": 1790941620 },
"secondary": { "usedPercent": 56, "windowDurationMins": 10080, "resetsAt": 1791217800 }
},
"other_model": { "primary": { "usedPercent": 99, "windowDurationMins": 300, "resetsAt": 1790941620 } }
}
}
+12
View File
@@ -0,0 +1,12 @@
Usage • Example plan Resets Oct 30
Monthly plan and on-demand usage
Category Current Usage
Included 44% used █████░░░░░
Auto 48% used █████░░░░░
API 0% used ░░░░░░░░░░
On-Demand Disabled ——————————
On-demand usage is off
View in dashboard: cursor.com/dashboard?tab=usage
+1
View File
@@ -0,0 +1 @@
active
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1791426274,
"narHash": "sha256-MWhktfFtFE8Jl7bf9Pkp7TR5MSdYCjCswSo0Gkb4ywc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7c8764b7c7b09b34f632464276218ef9090eaa11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+30
View File
@@ -0,0 +1,30 @@
{
description = "AI Usage Monitor for Hyprland";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
each = nixpkgs.lib.genAttrs systems;
in {
packages = each (system:
let
pkgs = import nixpkgs { inherit system; };
in {
ai-usage = pkgs.rustPlatform.buildRustPackage {
pname = "ai-usage";
version = "0.1.1";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
meta = {
description = "Waybar usage monitor for Claude, Codex and Cursor";
mainProgram = "ai-usage";
};
};
default = self.packages.${system}.ai-usage;
});
homeManagerModules.default = ./modules/home-manager.nix;
};
}
+56
View File
@@ -0,0 +1,56 @@
{ config, lib, pkgs, ... }:
let
cfg = config.programs.ai-usage;
in {
options.programs.ai-usage = {
enable = lib.mkEnableOption "AI Usage Monitor for Waybar";
package = lib.mkOption {
type = lib.types.package;
description = "ai-usage package from this flake.";
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
{
home.packages = [ cfg.package ];
systemd.user.services.ai-usage = {
Unit = {
Description = "AI Usage Monitor";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${lib.getExe cfg.package} daemon";
Restart = "on-failure";
RestartSec = 2;
Environment = [
"DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus"
"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
"PATH=${lib.makeBinPath [ pkgs.sqlite ]}:%h/.local/bin:%h/.nix-profile/bin:/etc/profiles/per-user/%u/bin:/run/current-system/sw/bin"
];
};
Install.WantedBy = [ "graphical-session.target" ];
};
}
(lib.mkIf config.programs.waybar.enable {
programs.waybar.settings.mainBar = {
"custom/ai-usage" = {
exec = "${lib.getExe cfg.package} waybar";
return-type = "json";
interval = 60;
signal = 1;
on-click = "${lib.getExe cfg.package} refresh";
tooltip = true;
};
modules-right = lib.mkBefore [ "custom/ai-usage" ];
};
programs.waybar.style = lib.mkAfter ''
#custom-ai-usage.ok { color: #a6e3a1; }
#custom-ai-usage.warning { color: #fab387; }
#custom-ai-usage.critical { color: #f38ba8; }
#custom-ai-usage.unknown { color: #cdd6f4; }
'';
})
]);
}
+215
View File
@@ -0,0 +1,215 @@
use std::time::Duration;
use futures_util::{Stream, StreamExt};
use tokio::sync::watch;
use zbus::proxy::CacheProperties;
use zbus::{proxy, Connection};
/// Unknown session details fail closed. Lock and sleep stay separate so a wake
/// does not clear a lock.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Activity {
pub system_awake: bool,
pub screen_awake: Option<bool>,
pub session_active: Option<bool>,
pub unlocked: Option<bool>,
}
impl Activity {
pub fn unknown() -> Self {
Self {
system_awake: true,
screen_awake: None,
session_active: None,
unlocked: None,
}
}
pub fn is_active(self) -> bool {
self.system_awake && self.screen_awake == Some(true) && self.session_active == Some(true) && self.unlocked == Some(true)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogindSample {
pub sleeping: bool,
pub locked_hint: Option<bool>,
pub state: Option<String>,
}
pub fn activity_from(sample: Option<&LogindSample>) -> Activity {
let Some(sample) = sample else {
return Activity::unknown();
};
Activity {
system_awake: !sample.sleeping,
screen_awake: Some(true),
session_active: sample
.state
.as_deref()
.map(|state| state == "active" || state == "online"),
unlocked: sample.locked_hint.map(|locked| !locked),
}
}
pub async fn watch(sender: watch::Sender<Activity>) {
loop {
if let Err(error) = follow(&sender).await {
eprintln!("ai-usage: logind unavailable ({error}); updates paused");
let _ = sender.send(Activity::unknown());
tokio::time::sleep(Duration::from_secs(15)).await;
}
}
}
async fn follow(sender: &watch::Sender<Activity>) -> zbus::Result<()> {
let connection = Connection::system().await?;
let session = LoginSessionProxy::builder(&connection)
.cache_properties(CacheProperties::No)
.path("/org/freedesktop/login1/session/auto")?
.build()
.await?;
let mut sleeping = false;
publish(sender, &session, sleeping).await?;
let manager = LoginManagerProxy::builder(&connection)
.cache_properties(CacheProperties::No)
.build()
.await
.ok();
let mut sleep_signals = match manager {
Some(manager) => match manager.receive_prepare_for_sleep().await {
Ok(stream) => Some(stream),
Err(error) => {
eprintln!("ai-usage: sleep signal unavailable ({error}); polling the session");
None
}
},
None => None,
};
let mut lock_signals = session.receive_lock().await.ok();
let mut unlock_signals = session.receive_unlock().await.ok();
let mut locked_changed = Some(session.receive_locked_hint_changed().await);
loop {
tokio::select! {
signal = next_item(&mut sleep_signals) => {
match signal {
Some(signal) => {
if let Ok(args) = signal.args() {
sleeping = args.start;
publish(sender, &session, sleeping).await?;
}
}
None => sleep_signals = None,
}
}
signal = next_item(&mut lock_signals) => {
if signal.is_some() {
let _ = sender.send(locked_activity(sender.borrow().clone(), false, sleeping));
} else {
lock_signals = None;
}
}
signal = next_item(&mut unlock_signals) => {
if signal.is_some() {
publish(sender, &session, sleeping).await?;
} else {
unlock_signals = None;
}
}
change = next_item(&mut locked_changed) => {
if change.is_some() {
publish(sender, &session, sleeping).await?;
} else {
locked_changed = None;
}
}
_ = tokio::time::sleep(Duration::from_secs(5)) => {
publish(sender, &session, sleeping).await?;
}
}
}
}
async fn next_item<S>(stream: &mut Option<S>) -> Option<S::Item>
where
S: Stream + Unpin,
{
match stream.as_mut() {
Some(stream) => stream.next().await,
None => std::future::pending().await,
}
}
async fn publish(sender: &watch::Sender<Activity>, session: &LoginSessionProxy<'_>, sleeping: bool) -> zbus::Result<()> {
let locked_hint = session.locked_hint().await?;
let state = session.state().await.unwrap_or_else(|_| "active".to_string());
let _ = sender.send(activity_from(Some(&LogindSample {
sleeping,
locked_hint: Some(locked_hint),
state: Some(state),
})));
Ok(())
}
fn locked_activity(mut activity: Activity, unlocked: bool, sleeping: bool) -> Activity {
activity.system_awake = !sleeping;
activity.unlocked = Some(unlocked);
activity
}
#[proxy(
interface = "org.freedesktop.login1.Manager",
default_service = "org.freedesktop.login1",
default_path = "/org/freedesktop/login1"
)]
trait LoginManager {
#[zbus(signal)]
fn prepare_for_sleep(&self, start: bool) -> zbus::Result<()>;
}
#[proxy(
interface = "org.freedesktop.login1.Session",
default_service = "org.freedesktop.login1"
)]
trait LoginSession {
#[zbus(property)]
fn locked_hint(&self) -> zbus::Result<bool>;
#[zbus(property)]
fn state(&self) -> zbus::Result<String>;
#[zbus(signal)]
fn lock(&self) -> zbus::Result<()>;
#[zbus(signal)]
fn unlock(&self) -> zbus::Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_and_locked_sessions_pause() {
let active = activity_from(Some(&LogindSample {
sleeping: false,
locked_hint: Some(false),
state: Some("active".to_string()),
}));
assert!(active.is_active());
let mut sleeping = LogindSample {
sleeping: true,
locked_hint: Some(false),
state: Some("active".to_string()),
};
assert!(!activity_from(Some(&sleeping)).is_active());
sleeping.sleeping = false;
sleeping.locked_hint = Some(true);
assert!(!activity_from(Some(&sleeping)).is_active());
assert!(!activity_from(None).is_active());
sleeping.locked_hint = None;
assert!(!activity_from(Some(&sleeping)).is_active());
}
}
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const allowed = new Set([
'/aiserver.v1.DashboardService/GetMe',
'/aiserver.v1.DashboardService/GetTeams',
'/aiserver.v1.DashboardService/GetUserPrivacyMode',
'/aiserver.v1.DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam',
'/aiserver.v1.DashboardService/GetCurrentPeriodUsage',
'/aiserver.v1.DashboardService/GetHardLimit',
'/aiserver.v1.DashboardService/GetPlanInfo',
'/aiserver.v1.AiService/FetchServerConfig',
'/aiserver.v1.AiService/AvailableModels',
'/aiserver.v1.AiService/GetUsableModels',
'/aiserver.v1.AiService/GetDefaultModelForCli',
'/aiserver.v1.AiService/GetTeamSettings',
'/aiserver.v1.ServerConfigService/GetServerConfig',
'/aiserver.v1.AnalyticsService/BootstrapStatsig'
]);
function check(url) {
const value = new URL(url);
if (value.protocol === 'data:' || value.protocol === 'blob:') return;
if (value.protocol !== 'https:' || value.hostname !== 'api2.cursor.sh' ||
(value.port && value.port !== '443') || value.username || value.password ||
value.search || !allowed.has(value.pathname)) {
throw new Error('AI Usage Monitor restricted a non-usage CLI network request');
}
}
const originalFetch = globalThis.fetch;
globalThis.fetch = function(input, options) {
try { check(typeof input === 'string' || input instanceof URL ? input : input.url); }
catch (error) { return Promise.reject(error); }
return originalFetch.call(this, input, { ...options, redirect: 'error' });
};
for (const name of ['http', 'https']) {
const module = require('node:' + name);
const request = module.request;
module.request = function(input, options, callback) {
let url;
if (typeof input === 'string' || input instanceof URL) {
const parsed = new URL(input);
if (options && typeof options === 'object') {
const host = options.hostname || options.host;
if (host) parsed.host = host;
if (options.port) parsed.port = String(options.port);
if (options.path) url = parsed.origin + options.path;
}
url ||= parsed.href;
} else {
url = (input.protocol || name + ':') + '//' + (input.hostname || input.host) +
(input.port ? ':' + input.port : '') + (input.path || '/');
}
check(url);
return request.apply(this, arguments);
};
module.get = function() { const req = module.request.apply(this, arguments); req.end(); return req; };
}
const http2 = require('node:http2');
const connect = http2.connect;
http2.connect = function(authority) {
const origin = new URL(authority);
if (origin.protocol !== 'https:' || origin.hostname !== 'api2.cursor.sh' ||
(origin.port && origin.port !== '443') || origin.username || origin.password) {
throw new Error('AI Usage Monitor restricted a non-usage CLI connection');
}
const session = connect.apply(this, arguments);
const request = session.request;
session.request = function(headers) {
if (headers[':authority'] && headers[':authority'] !== origin.host) {
throw new Error('AI Usage Monitor restricted a non-usage CLI connection');
}
check(origin.origin + headers[':path']);
return request.apply(this, arguments);
};
return session;
};
const child = require('node:child_process');
function baseName(file) {
if (typeof file !== 'string') return '';
const parts = file.split('/');
return parts[parts.length - 1];
}
for (const name of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) {
const original = child[name];
child[name] = function(file, args, options) {
if (name === 'spawn' && baseName(file) === 'secret-tool' && Array.isArray(args) &&
args[0] === 'lookup' && !(options && options.shell)) {
return original.apply(this, arguments);
}
throw new Error('AI Usage Monitor restricted a CLI subprocess');
};
}
require('node:module').syncBuiltinESMExports();
+294
View File
@@ -0,0 +1,294 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixListener;
use tokio::sync::{watch, Notify};
use crate::activity::{self, Activity};
use crate::paths;
use crate::providers;
use crate::refresh::{Engine, Reason};
use crate::settings::Settings;
use crate::waybar;
pub async fn serve() -> std::io::Result<()> {
let settings = Settings::load_default();
let snapshot = crate::model::UsageSnapshot::read(&paths::snapshot_path()).unwrap_or_else(|_| {
eprintln!("ai-usage: saved usage snapshot is unreadable; starting empty");
crate::model::UsageSnapshot::default()
});
let engine = Engine::new(snapshot, settings);
let state = Arc::new(tokio::sync::Mutex::new(Daemon::new(engine)));
let notify = Arc::new(Notify::new());
let (activity_tx, activity_rx) = watch::channel(Activity::unknown());
let cancel = state.lock().await.cancel.clone();
tokio::spawn(activity::watch(activity_tx));
tokio::spawn(listen(state.clone(), notify.clone()));
let mut activity_rx = activity_rx;
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
loop {
let activity = *activity_rx.borrow();
{
let mut daemon = state.lock().await;
daemon.activity = activity;
if !activity.is_active() {
daemon.cancel.store(true, Ordering::Relaxed);
}
}
if activity.is_active() {
reload_settings(&state).await;
let manual = {
let mut daemon = state.lock().await;
let manual = daemon.manual;
daemon.manual = false;
manual
};
refresh_due(&state, if manual { Reason::Manual } else { Reason::Timer }).await;
}
let delay = {
let daemon = state.lock().await;
if daemon.activity.is_active() {
daemon
.engine
.next_delay(Utc::now())
.map(|duration| Duration::from_millis(duration.num_milliseconds().max(10) as u64))
.unwrap_or(Duration::from_secs(60))
} else {
Duration::from_secs(5)
}
};
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = notify.notified() => {}
_ = activity_rx.changed() => {}
_ = tokio::signal::ctrl_c() => break,
_ = terminate.recv() => break,
}
let _ = cancel;
}
Ok(())
}
struct Daemon {
engine: Engine,
activity: Activity,
manual: bool,
cancel: Arc<AtomicBool>,
config_stamp: Option<std::time::SystemTime>,
}
impl Daemon {
fn new(engine: Engine) -> Self {
Self {
engine,
activity: Activity::unknown(),
manual: true,
cancel: Arc::new(AtomicBool::new(false)),
config_stamp: None,
}
}
}
async fn reload_settings(state: &Arc<tokio::sync::Mutex<Daemon>>) {
let path = paths::config_path();
let stamp = std::fs::metadata(&path).and_then(|metadata| metadata.modified()).ok();
let mut daemon = state.lock().await;
if stamp == daemon.config_stamp && daemon.config_stamp.is_some() {
return;
}
daemon.config_stamp = stamp;
let settings = Settings::load(&path);
daemon.engine.apply_settings(settings);
let snapshot = daemon.engine.snapshot.clone();
drop(daemon);
if snapshot.write(&paths::snapshot_path()).is_ok() {
signal_waybar();
}
}
async fn refresh_due(state: &Arc<tokio::sync::Mutex<Daemon>>, reason: Reason) {
let (round, settings, cancel) = {
let mut daemon = state.lock().await;
if !daemon.activity.is_active() {
return;
}
let now = Utc::now();
let Some(round) = daemon.engine.select(reason, now) else {
return;
};
daemon.cancel = Arc::new(AtomicBool::new(false));
(round, daemon.engine.settings.clone(), daemon.cancel.clone())
};
let mut joins = tokio::task::JoinSet::new();
for provider in round.providers.clone() {
let cancel = cancel.clone();
let override_path = settings.override_for(provider).map(str::to_string);
joins.spawn_blocking(move || {
let result = providers::fetch(provider, override_path.as_deref(), &cancel);
(provider, result)
});
}
let mut results = std::collections::HashMap::new();
while let Some(joined) = joins.join_next().await {
match joined {
Ok((provider, result)) => {
results.insert(provider, result);
}
Err(_) => {}
}
}
let cancelled = cancel.load(Ordering::Relaxed);
let mut daemon = state.lock().await;
if cancelled || !daemon.activity.is_active() {
daemon.engine.commit(round, results, true);
return;
}
if daemon.engine.commit(round, results, false) {
let snapshot = daemon.engine.snapshot.clone();
drop(daemon);
if let Err(error) = snapshot.write(&paths::snapshot_path()) {
eprintln!("ai-usage: could not save usage snapshot ({error:?})");
} else {
signal_waybar();
}
eprintln!("ai-usage: provider refresh completed");
}
}
async fn listen(state: Arc<tokio::sync::Mutex<Daemon>>, notify: Arc<Notify>) {
let path = paths::socket_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::remove_file(&path);
let listener = match UnixListener::bind(&path) {
Ok(listener) => listener,
Err(error) => {
eprintln!("ai-usage: control socket unavailable ({error})");
return;
}
};
loop {
let Ok((stream, _)) = listener.accept().await else {
continue;
};
let state = state.clone();
let notify = notify.clone();
tokio::spawn(async move {
let _ = handle(stream, state, notify).await;
});
}
}
async fn handle(
stream: tokio::net::UnixStream,
state: Arc<tokio::sync::Mutex<Daemon>>,
notify: Arc<Notify>,
) -> std::io::Result<()> {
let (read, mut write) = stream.into_split();
let mut line = String::new();
BufReader::new(read).read_line(&mut line).await?;
match line.trim() {
"refresh" => {
{
let mut daemon = state.lock().await;
if !daemon.activity.is_active() {
drop(daemon);
write.write_all(b"paused\n").await?;
return Ok(());
}
daemon.manual = true;
}
notify.notify_one();
tokio::time::sleep(Duration::from_millis(50)).await;
let started = Utc::now();
loop {
if !state.lock().await.manual && !refreshing_recent(&state, started).await {
break;
}
if Utc::now().signed_duration_since(started).num_seconds() > 45 {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
write.write_all(b"ok\n").await?;
}
"status" => {
let daemon = state.lock().await;
let text = waybar::status_text(
&daemon.engine.snapshot,
daemon.activity.is_active(),
Utc::now(),
&daemon.engine.settings.enabled_providers,
);
write.write_all(text.as_bytes()).await?;
write.write_all(b"\n").await?;
}
_ => write.write_all(b"unknown\n").await?,
}
Ok(())
}
async fn refreshing_recent(state: &Arc<tokio::sync::Mutex<Daemon>>, started: chrono::DateTime<Utc>) -> bool {
let daemon = state.lock().await;
daemon.engine.snapshot.written_at < started
}
fn signal_waybar() {
let Ok(entries) = std::fs::read_dir("/proc") else {
return;
};
let signal = libc::SIGRTMIN() + 1;
for entry in entries.flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<i32>() else {
continue;
};
let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) else {
continue;
};
if comm.trim() == "waybar" {
unsafe {
libc::kill(pid, signal);
}
}
}
}
pub fn request_refresh() -> std::io::Result<()> {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(paths::socket_path()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::NotFound, "ai-usage daemon is not running")
})?;
stream.set_read_timeout(Some(Duration::from_secs(50)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
stream.write_all(b"refresh\n")?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
print!("{response}");
if response.trim() == "paused" {
std::process::exit(0);
}
if response.trim() != "ok" {
return Err(std::io::Error::other("refresh failed"));
}
signal_waybar();
Ok(())
}
pub fn request_status() -> std::io::Result<String> {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(paths::socket_path()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::NotFound, "ai-usage daemon is not running")
})?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.write_all(b"status\n")?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
Ok(response)
}
+227
View File
@@ -0,0 +1,227 @@
use std::cmp::Ordering;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use crate::model::Provider;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutableCandidate {
pub path: PathBuf,
pub origin: &'static str,
}
pub fn candidates(
provider: Provider,
override_path: Option<&str>,
home: &Path,
path: &str,
) -> Vec<ExecutableCandidate> {
if let Some(override_path) = override_path.map(str::trim).filter(|value| !value.is_empty()) {
return vec![ExecutableCandidate {
path: expand_tilde(override_path, home),
origin: "Manual override",
}];
}
let binaries: &[&str] = match provider {
Provider::Cursor => &["cursor-agent", "agent"],
Provider::Claude => &["claude"],
Provider::Codex => &["codex"],
};
let mut result = Vec::new();
for directory in path.split(':').filter(|directory| directory.starts_with('/')) {
for binary in binaries {
result.push(ExecutableCandidate {
path: PathBuf::from(directory).join(binary),
origin: "PATH",
});
}
}
let mut local = vec![
PathBuf::from("/usr/local/bin"),
PathBuf::from("/opt/homebrew/bin"),
home.join(".local/bin"),
home.join(".claude/local"),
home.join(".npm-global/bin"),
home.join(".nix-profile/bin"),
PathBuf::from("/run/current-system/sw/bin"),
];
if let Ok(user) = std::env::var("USER") {
local.push(PathBuf::from(format!("/etc/profiles/per-user/{user}/bin")));
}
for directory in local {
for binary in binaries {
result.push(ExecutableCandidate {
path: directory.join(binary),
origin: "Local installation",
});
}
}
if provider != Provider::Cursor {
let extensions = home.join(".cursor/extensions");
let prefix = match provider {
Provider::Claude => "anthropic.claude-code-",
Provider::Codex => "openai.chatgpt-",
Provider::Cursor => "",
};
let mut folders = fs::read_dir(&extensions)
.ok()
.into_iter()
.flatten()
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with(prefix))
.collect::<Vec<_>>();
folders.sort_by(|left, right| natural_cmp(right, left));
let suffixes: &[&str] = match provider {
Provider::Claude => &["resources/native-binary/claude"],
Provider::Codex => &[
"bin/linux-x86_64/codex",
"bin/linux-aarch64/codex",
],
Provider::Cursor => &[],
};
for folder in folders {
for suffix in suffixes {
result.push(ExecutableCandidate {
path: extensions.join(&folder).join(suffix),
origin: "Cursor extension",
});
}
}
}
let mut seen = std::collections::HashSet::new();
result
.into_iter()
.filter(|candidate| is_executable(&candidate.path))
.filter(|candidate| {
let key = if provider == Provider::Cursor {
fs::canonicalize(&candidate.path).unwrap_or_else(|_| candidate.path.clone())
} else {
candidate.path.clone()
};
seen.insert(key)
})
.collect()
}
fn expand_tilde(path: &str, home: &Path) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
home.join(rest)
} else if path == "~" {
home.to_path_buf()
} else {
PathBuf::from(path)
}
}
fn is_executable(path: &Path) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}
fn natural_cmp(left: &str, right: &str) -> Ordering {
let mut left_chars = left.chars().peekable();
let mut right_chars = right.chars().peekable();
loop {
match (left_chars.peek().copied(), right_chars.peek().copied()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(left_char), Some(right_char)) if left_char.is_ascii_digit() && right_char.is_ascii_digit() => {
let left_number = take_number(&mut left_chars);
let right_number = take_number(&mut right_chars);
match left_number.cmp(&right_number) {
Ordering::Equal => {}
other => return other,
}
}
(Some(left_char), Some(right_char)) => {
left_chars.next();
right_chars.next();
match left_char.cmp(&right_char) {
Ordering::Equal => {}
other => return other,
}
}
}
}
}
fn take_number(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> u64 {
let mut number = 0u64;
while let Some(char) = chars.peek().copied() {
if let Some(digit) = char.to_digit(10) {
number = number.saturating_mul(10).saturating_add(u64::from(digit));
chars.next();
} else {
break;
}
}
number
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn temp_home() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"ai-usage-home-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn cursor_agent_is_discovered_outside_path_and_editor_is_ignored() {
let home = temp_home();
let binary = home.join(".local/bin/cursor-agent");
fs::create_dir_all(binary.parent().unwrap()).unwrap();
fs::write(&binary, []).unwrap();
std::os::unix::fs::symlink(&binary, home.join(".local/bin/agent")).unwrap();
let mode = fs::Permissions::from_mode(0o755);
fs::set_permissions(&binary, mode).unwrap();
let found = candidates(Provider::Cursor, None, &home, "");
assert!(found.iter().any(|candidate| candidate.path == binary));
assert!(found.iter().all(|candidate| !candidate.path.ends_with("cursor")));
assert!(found.iter().all(|candidate| candidate.path.file_name().unwrap() != "agent"));
let _ = fs::remove_dir_all(home);
}
#[test]
fn missing_manual_override_is_kept() {
let found = candidates(Provider::Claude, Some("/does/not/exist"), Path::new("/tmp"), "");
assert_eq!(found.len(), 1);
assert_eq!(found[0].origin, "Manual override");
}
#[test]
fn newest_extension_wins_when_there_is_no_path_binary() {
let home = temp_home();
for version in ["2.1.9", "2.1.10"] {
let executable = home.join(format!(
".cursor/extensions/anthropic.claude-code-{version}-linux-x64/resources/native-binary/claude"
));
fs::create_dir_all(executable.parent().unwrap()).unwrap();
fs::write(&executable, []).unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
}
let found = candidates(Provider::Claude, None, &home, "");
let extensions: Vec<_> = found
.iter()
.filter(|candidate| candidate.origin == "Cursor extension")
.collect();
assert_eq!(extensions.len(), 2);
assert!(extensions[0].path.display().to_string().contains("2.1.10"));
let _ = fs::remove_dir_all(home);
}
}
+161
View File
@@ -0,0 +1,161 @@
use chrono::{DateTime, Datelike, Timelike, Utc};
use crate::model::UsageWindow;
pub fn percentage(window: Option<&UsageWindow>, show_used: bool) -> String {
let Some(window) = window else {
return "—".to_string();
};
let value = if show_used {
window.used_percentage
} else {
window.remaining_percentage
};
match value {
Some(value) => format!(
"{}% {}",
value.round() as i64,
if show_used { "used" } else { "left" }
),
None => "—".to_string(),
}
}
pub fn bar_number(window: Option<&UsageWindow>, show_used: bool) -> String {
let Some(window) = window else {
return "—".to_string();
};
let value = if show_used {
window.used_percentage
} else {
window.remaining_percentage
};
match value {
Some(value) => format!("{}%", value.round() as i64),
None => "—".to_string(),
}
}
pub fn reset(date: Option<DateTime<Utc>>, now: DateTime<Utc>, compact: bool) -> String {
let Some(date) = date else {
return "Reset unknown".to_string();
};
let local_date = date.with_timezone(&chrono::Local);
let local_now = now.with_timezone(&chrono::Local);
let clock = format!("{:02}:{:02}", local_date.hour(), local_date.minute());
if local_date.date_naive() == local_now.date_naive() {
return if compact {
clock
} else {
format!("Today {clock}")
};
}
let weekday = weekday_name(local_date.weekday());
if compact {
format!("{weekday} {clock}")
} else {
format!(
"{weekday} {} {} {clock}",
local_date.day(),
month_name(local_date.month())
)
}
}
pub fn relative_reset(date: Option<DateTime<Utc>>, now: DateTime<Utc>) -> String {
let Some(date) = date else {
return "Reset unknown".to_string();
};
let seconds = (date - now).num_seconds();
if seconds <= 0 {
return "Awaiting reset update".to_string();
}
let minutes = (seconds + 59) / 60;
if minutes >= 1440 {
return format!("Reset in {}d {}h", minutes / 1440, (minutes % 1440) / 60);
}
if minutes >= 60 {
return format!("Reset in {}h {}m", minutes / 60, minutes % 60);
}
format!("Reset in {minutes}m")
}
pub fn clock(date: Option<DateTime<Utc>>) -> String {
let Some(date) = date else {
return "Never".to_string();
};
let local = date.with_timezone(&chrono::Local);
format!("{:02}:{:02}", local.hour(), local.minute())
}
pub fn parse_iso8601(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|parsed| parsed.with_timezone(&Utc))
}
fn weekday_name(weekday: chrono::Weekday) -> &'static str {
match weekday {
chrono::Weekday::Mon => "Mon",
chrono::Weekday::Tue => "Tue",
chrono::Weekday::Wed => "Wed",
chrono::Weekday::Thu => "Thu",
chrono::Weekday::Fri => "Fri",
chrono::Weekday::Sat => "Sat",
chrono::Weekday::Sun => "Sun",
}
}
fn month_name(month: u32) -> &'static str {
match month {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
_ => "Dec",
}
}
pub fn level(remaining: Option<f64>) -> &'static str {
match remaining {
Some(remaining) if remaining > 50.0 => "ok",
Some(remaining) if remaining >= 20.0 => "warning",
Some(_) => "critical",
None => "unknown",
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn relative_reset_rounds_up_and_rejects_the_past() {
let now = Utc.with_ymd_and_hms(2026, 10, 9, 12, 0, 0).unwrap();
assert_eq!(
relative_reset(Some(now + chrono::Duration::seconds(90)), now),
"Reset in 2m"
);
assert_eq!(
relative_reset(Some(now - chrono::Duration::seconds(1)), now),
"Awaiting reset update"
);
assert_eq!(relative_reset(None, now), "Reset unknown");
}
#[test]
fn percentage_uses_the_selected_side() {
let window = UsageWindow::new(Some(62.0), None, None);
assert_eq!(percentage(Some(&window), false), "38% left");
assert_eq!(bar_number(Some(&window), true), "62%");
assert_eq!(percentage(None, false), "—");
}
}
+66
View File
@@ -0,0 +1,66 @@
mod activity;
mod daemon;
mod discovery;
mod formatting;
mod model;
mod parsers;
mod paths;
mod process;
mod protocol;
mod providers;
mod refresh;
mod settings;
mod store;
mod waybar;
use chrono::Utc;
#[tokio::main]
async fn main() {
let command = std::env::args().nth(1);
match command.as_deref() {
Some("daemon") => {
if let Err(error) = daemon::serve().await {
eprintln!("ai-usage: {error}");
std::process::exit(1);
}
}
Some("waybar") => {
let snapshot = model::UsageSnapshot::read(&paths::snapshot_path()).unwrap_or_default();
let settings = settings::Settings::load(&paths::config_path());
println!(
"{}",
waybar::json(&waybar::render(&snapshot, Utc::now(), &settings.enabled_providers))
);
}
Some("refresh") => {
if let Err(error) = daemon::request_refresh() {
eprintln!("ai-usage: {error}");
std::process::exit(1);
}
}
Some("status") => match daemon::request_status() {
Ok(text) => print!("{text}"),
Err(error) => {
let snapshot = model::UsageSnapshot::read(&paths::snapshot_path()).unwrap_or_default();
let settings = settings::Settings::load(&paths::config_path());
println!(
"{}",
waybar::status_text(&snapshot, false, Utc::now(), &settings.enabled_providers)
);
eprintln!("ai-usage: {error}");
std::process::exit(1);
}
},
_ => {
eprintln!(
"Usage: ai-usage <daemon|waybar|refresh|status>\n\n\
daemon refresh Claude, Codex and Cursor in the background\n\
waybar print one Waybar JSON line from the saved snapshot\n\
refresh ask the daemon to refresh now\n\
status print the current snapshot"
);
std::process::exit(2);
}
}
}
+355
View File
@@ -0,0 +1,355 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const STALE_AFTER_SECS: i64 = 30 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Provider {
Claude,
Codex,
Cursor,
}
impl Provider {
pub const ALL: [Provider; 3] = [Provider::Claude, Provider::Codex, Provider::Cursor];
pub fn as_str(self) -> &'static str {
match self {
Provider::Claude => "claude",
Provider::Codex => "codex",
Provider::Cursor => "cursor",
}
}
pub fn display_name(self) -> &'static str {
match self {
Provider::Claude => "Claude",
Provider::Codex => "Codex",
Provider::Cursor => "Cursor",
}
}
pub fn bar_label(self) -> &'static str {
match self {
Provider::Claude => "C",
Provider::Codex => "X",
Provider::Cursor => "Cu",
}
}
}
fn clamp(value: Option<f64>) -> Option<f64> {
let value = value?;
if !value.is_finite() {
return None;
}
Some(value.clamp(0.0, 100.0))
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageWindow {
pub used_percentage: Option<f64>,
pub remaining_percentage: Option<f64>,
#[serde(default, with = "optional_rfc3339")]
pub reset_at: Option<DateTime<Utc>>,
}
impl UsageWindow {
pub fn new(
used_percentage: Option<f64>,
remaining_percentage: Option<f64>,
reset_at: Option<DateTime<Utc>>,
) -> Self {
let used = clamp(used_percentage);
let remaining = clamp(remaining_percentage);
Self {
used_percentage: used.or_else(|| remaining.map(|value| 100.0 - value)),
remaining_percentage: remaining.or_else(|| used.map(|value| 100.0 - value)),
reset_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageMetric {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BillingUsagePool {
pub name: String,
pub window: UsageWindow,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderUsage {
pub provider: Provider,
#[serde(with = "rfc3339")]
pub fetched_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short_window: Option<UsageWindow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weekly_window: Option<UsageWindow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing_window: Option<UsageWindow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing_reset_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing_pools: Option<Vec<BillingUsagePool>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_usage: Vec<UsageMetric>,
}
impl ProviderUsage {
pub fn display_billing_pools(&self) -> Vec<BillingUsagePool> {
if let Some(pools) = &self.billing_pools {
return pools.clone();
}
if self.provider != Provider::Cursor {
return Vec::new();
}
[("Auto", "Cursor models"), ("API", "Other models")]
.into_iter()
.filter_map(|(old_name, name)| {
let metric = self.additional_usage.iter().find(|metric| {
metric.name == old_name || metric.name == name
})?;
let value = metric.value.strip_suffix("% used")?;
if !regex_percent(value) {
return None;
}
let used = value.parse::<f64>().ok()?;
if !used.is_finite() {
return None;
}
Some(BillingUsagePool {
name: name.to_string(),
window: UsageWindow::new(Some(used), None, None),
})
})
.collect()
}
pub fn display_additional_usage(&self) -> Vec<UsageMetric> {
if self.provider != Provider::Cursor {
return self.additional_usage.clone();
}
let pools: Vec<String> = self
.display_billing_pools()
.into_iter()
.map(|pool| pool.name)
.collect();
self.additional_usage
.iter()
.filter(|metric| {
let name = match metric.name.as_str() {
"Auto" => "Cursor models",
"API" => "Other models",
other => other,
};
!pools.iter().any(|pool| pool == name)
})
.cloned()
.collect()
}
}
fn regex_percent(value: &str) -> bool {
let mut seen_dot = false;
let mut digits = false;
for char in value.chars() {
if char.is_ascii_digit() {
digits = true;
continue;
}
if char == '.' && !seen_dot && digits {
seen_dot = true;
digits = false;
continue;
}
return false;
}
digits
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FetchStatus {
Available,
Disabled,
NotSignedIn,
Unsupported,
Failed,
Pending,
}
impl FetchStatus {
pub fn label(self) -> &'static str {
match self {
FetchStatus::Available => "Available",
FetchStatus::Disabled => "Disabled",
FetchStatus::NotSignedIn => "Sign in with the provider",
FetchStatus::Unsupported => "Usage unavailable",
FetchStatus::Failed => "Update failed",
FetchStatus::Pending => "Awaiting first update",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderFailure {
pub status: FetchStatus,
pub message: String,
}
impl ProviderFailure {
pub fn new(status: FetchStatus, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}
}
impl std::fmt::Display for ProviderFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ProviderFailure {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderState {
pub provider: Provider,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_successful_usage: Option<ProviderUsage>,
#[serde(default, with = "optional_rfc3339")]
pub last_attempt_at: Option<DateTime<Utc>>,
pub status: FetchStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl ProviderState {
pub fn pending(provider: Provider) -> Self {
Self {
provider,
last_successful_usage: None,
last_attempt_at: None,
status: FetchStatus::Pending,
message: None,
}
}
pub fn is_stale(&self, at: DateTime<Utc>) -> bool {
let Some(usage) = &self.last_successful_usage else {
return false;
};
(at - usage.fetched_at).num_seconds() > STALE_AFTER_SECS
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageSnapshot {
pub schema_version: u32,
#[serde(with = "rfc3339")]
pub written_at: DateTime<Utc>,
pub show_used_percentage: bool,
pub is_demo: bool,
pub providers: Vec<ProviderState>,
}
impl Default for UsageSnapshot {
fn default() -> Self {
Self {
schema_version: 1,
written_at: Utc::now(),
show_used_percentage: false,
is_demo: false,
providers: Provider::ALL
.into_iter()
.map(ProviderState::pending)
.collect(),
}
}
}
impl UsageSnapshot {
pub fn state(&self, provider: Provider) -> ProviderState {
self.providers
.iter()
.find(|state| state.provider == provider)
.cloned()
.unwrap_or_else(|| ProviderState::pending(provider))
}
pub fn state_mut(&mut self, provider: Provider) -> &mut ProviderState {
if let Some(index) = self
.providers
.iter()
.position(|state| state.provider == provider)
{
return &mut self.providers[index];
}
self.providers.push(ProviderState::pending(provider));
self.providers.last_mut().expect("provider just inserted")
}
}
mod rfc3339 {
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(value: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
let text = String::deserialize(deserializer)?;
DateTime::parse_from_rfc3339(&text)
.map(|value| value.with_timezone(&Utc))
.map_err(serde::de::Error::custom)
}
}
mod optional_rfc3339 {
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(value: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(value) => serializer.serialize_some(&value.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|text| {
DateTime::parse_from_rfc3339(&text)
.map(|parsed| parsed.with_timezone(&Utc))
.map_err(serde::de::Error::custom)
})
.transpose()
}
}
+442
View File
@@ -0,0 +1,442 @@
use chrono::{DateTime, TimeZone, Utc};
use serde::Deserialize;
use crate::formatting::parse_iso8601;
use crate::model::{
BillingUsagePool, FetchStatus, Provider, ProviderFailure, ProviderUsage, UsageMetric, UsageWindow,
};
pub fn parse_claude(data: &[u8], fetched_at: DateTime<Utc>) -> Result<ProviderUsage, ProviderFailure> {
let response: ClaudeResponse = serde_json::from_slice(data).map_err(|_| {
ProviderFailure::new(
FetchStatus::Unsupported,
"Claude's usage protocol changed. Check the detected CLI version.",
)
})?;
if response.rate_limits_available == Some(false) {
return Err(ProviderFailure::new(
FetchStatus::NotSignedIn,
"Subscription limits are unavailable. Check Claude's existing subscription login.",
));
}
let short = normalize_claude(response.rate_limits.as_ref().and_then(|limits| limits.five_hour.as_ref()))?;
let week = normalize_claude(response.rate_limits.as_ref().and_then(|limits| limits.seven_day.as_ref()))?;
if short.as_ref().and_then(|window| window.used_percentage).is_none()
&& week.as_ref().and_then(|window| window.used_percentage).is_none()
{
return Err(ProviderFailure::new(
FetchStatus::Failed,
"Claude returned no subscription usage. Open Claude Code and check /usage, then refresh. Last successful usage is retained.",
));
}
Ok(ProviderUsage {
provider: Provider::Claude,
fetched_at,
short_window: short,
weekly_window: week,
billing_window: None,
billing_reset_label: None,
billing_pools: None,
additional_usage: Vec::new(),
})
}
fn normalize_claude(window: Option<&ClaudeWindow>) -> Result<Option<UsageWindow>, ProviderFailure> {
let Some(window) = window else {
return Ok(None);
};
let reset_at = match &window.resets_at {
Some(text) => Some(parse_iso8601(text).ok_or_else(|| {
ProviderFailure::new(FetchStatus::Failed, "Claude returned an unreadable reset timestamp.")
})?),
None => None,
};
Ok(Some(UsageWindow::new(window.utilization, None, reset_at)))
}
#[derive(Deserialize)]
struct ClaudeResponse {
rate_limits_available: Option<bool>,
rate_limits: Option<ClaudeLimits>,
}
#[derive(Deserialize)]
struct ClaudeLimits {
five_hour: Option<ClaudeWindow>,
seven_day: Option<ClaudeWindow>,
}
#[derive(Deserialize)]
struct ClaudeWindow {
utilization: Option<f64>,
resets_at: Option<String>,
}
pub fn parse_codex(data: &[u8], fetched_at: DateTime<Utc>) -> Result<ProviderUsage, ProviderFailure> {
let response: CodexResponse = serde_json::from_slice(data).map_err(|_| {
ProviderFailure::new(
FetchStatus::Unsupported,
"Codex's usage protocol changed. Check the detected CLI version.",
)
})?;
let bucket = response
.rate_limits_by_limit_id
.as_ref()
.and_then(|buckets| buckets.get("codex"))
.or_else(|| {
response.rate_limits.as_ref().filter(|bucket| {
bucket.limit_id.as_deref().is_none_or(|id| id == "codex")
})
});
let Some(bucket) = bucket else {
return Err(ProviderFailure::new(
FetchStatus::Unsupported,
"Codex returned no subscription rate-limit bucket.",
));
};
let mut short = None;
let mut week = None;
let mut metrics = Vec::new();
for window in [bucket.primary.as_ref(), bucket.secondary.as_ref()]
.into_iter()
.flatten()
{
let normalized = UsageWindow::new(
Some(window.used_percent),
None,
window.resets_at.map(|seconds| Utc.timestamp_opt(seconds as i64, 0).single().unwrap_or_else(Utc::now)),
);
match window.window_duration_mins {
Some(300) => short = Some(normalized),
Some(10080) => week = Some(normalized),
Some(minutes) => metrics.push(UsageMetric {
name: format!("{minutes}m window"),
value: crate::formatting::percentage(Some(&normalized), false),
}),
None => metrics.push(UsageMetric {
name: "Unspecified window".to_string(),
value: crate::formatting::percentage(Some(&normalized), false),
}),
}
}
if short.is_none() && week.is_none() && metrics.is_empty() {
return Err(ProviderFailure::new(
FetchStatus::Failed,
"Codex returned no usage windows.",
));
}
Ok(ProviderUsage {
provider: Provider::Codex,
fetched_at,
short_window: short,
weekly_window: week,
billing_window: None,
billing_reset_label: None,
billing_pools: None,
additional_usage: metrics,
})
}
#[derive(Deserialize)]
struct CodexResponse {
#[serde(rename = "rateLimits")]
rate_limits: Option<CodexBucket>,
#[serde(rename = "rateLimitsByLimitId")]
rate_limits_by_limit_id: Option<std::collections::BTreeMap<String, CodexBucket>>,
}
#[derive(Deserialize)]
struct CodexBucket {
#[serde(rename = "limitId")]
limit_id: Option<String>,
primary: Option<CodexWindow>,
secondary: Option<CodexWindow>,
}
#[derive(Deserialize)]
struct CodexWindow {
#[serde(rename = "usedPercent")]
used_percent: f64,
#[serde(rename = "windowDurationMins")]
window_duration_mins: Option<i64>,
#[serde(rename = "resetsAt")]
resets_at: Option<f64>,
}
pub fn terminal_text(data: &[u8]) -> String {
let text = String::from_utf8_lossy(data);
let without_osc = OSC_RE.replace_all(&text, "");
let without_csi = CSI_RE.replace_all(&without_osc, "");
without_csi.replace('\r', "")
}
pub fn parse_cursor(data: &[u8], fetched_at: DateTime<Utc>) -> Result<ProviderUsage, ProviderFailure> {
let text = terminal_text(data);
let Some(header) = text.rfind("Monthly plan and on-demand usage") else {
return Err(ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor did not return a supported billing-usage screen.",
));
};
let before = &text[..header];
let screen = &text[header + "Monthly plan and on-demand usage".len()..];
let included = percent("Included", screen)?;
let auto = percent("Auto", screen)?;
let api = percent("API", screen)?;
let on_demand = capture(ON_DEMAND, screen).ok_or_else(|| {
ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor's usage screen is incomplete or changed. Check the Agent CLI version.",
)
})?;
if included.is_none() || auto.is_none() || api.is_none() {
return Err(ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor's usage screen is incomplete or changed. Check the Agent CLI version.",
));
}
let reset_source = before
.lines()
.rev()
.take(3)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
let reset = capture(RESET, &reset_source);
Ok(ProviderUsage {
provider: Provider::Cursor,
fetched_at,
short_window: None,
weekly_window: None,
billing_window: Some(UsageWindow::new(included, None, None)),
billing_reset_label: reset,
billing_pools: Some(vec![
BillingUsagePool {
name: "Cursor models".to_string(),
window: UsageWindow::new(auto, None, None),
},
BillingUsagePool {
name: "Other models".to_string(),
window: UsageWindow::new(api, None, None),
},
]),
additional_usage: vec![UsageMetric {
name: "On-demand".to_string(),
value: on_demand,
}],
})
}
fn percent(label: &str, screen: &str) -> Result<Option<f64>, ProviderFailure> {
let pattern = format!(r"(?m)^\s*{label}\s+([0-9]+(?:\.[0-9]+)?)% used\b");
let regex = regex::Regex::new(&pattern).map_err(|_| {
ProviderFailure::new(FetchStatus::Failed, "Cursor usage could not be read.")
})?;
let Some(found) = regex.captures(screen).and_then(|caps| caps.get(1)) else {
return Ok(None);
};
let number = found.as_str().parse::<f64>().ok().filter(|value| value.is_finite());
Ok(number)
}
fn capture(pattern: &str, value: &str) -> Option<String> {
regex::Regex::new(pattern)
.ok()?
.captures(value)?
.get(1)
.map(|item| item.as_str().to_string())
}
const ON_DEMAND: &str = r"(?m)^[ \t]*On-Demand[ \t]+(Disabled|Unavailable|\$[0-9,]+(?:\.[0-9]{1,2})?(?: / \$[0-9,]+(?:\.[0-9]{1,2})?)?)(?:[ \t]+[█░—]+)?[ \t]*\n";
const RESET: &str = r"Resets ((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?:[1-9]|[12][0-9]|3[01]))\b";
macro_rules! lazy_static_regex {
($name:ident, $pattern:expr) => {
static $name: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new($pattern).expect("static regex"));
};
}
pub fn subscription_status(text: &str) -> Option<UsageMetric> {
let status = text.trim();
const KNOWN: &[&str] = &[
"active",
"trialing",
"past_due",
"canceled",
"unpaid",
"incomplete",
"incomplete_expired",
"paused",
];
if !KNOWN.contains(&status) {
return None;
}
Some(UsageMetric {
name: "Subscription".to_string(),
value: status.replace('_', " "),
})
}
lazy_static_regex!(OSC_RE, r"\x1B\][\s\S]*?(?:\x07|\x1B\\)");
lazy_static_regex!(CSI_RE, r"\x1B\[[0-?]*[ -/]*[@-~]");
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> Vec<u8> {
match name {
"claude" => include_bytes!("../fixtures/claude.json").to_vec(),
"codex" => include_bytes!("../fixtures/codex.json").to_vec(),
"cursor" => include_bytes!("../fixtures/cursor.txt").to_vec(),
"cursor-usage" => include_bytes!("../fixtures/cursor-usage.txt").to_vec(),
_ => panic!("unknown fixture"),
}
}
#[test]
fn claude_windows_and_fractional_timestamp() {
let usage = parse_claude(&fixture("claude"), Utc::now()).unwrap();
assert_eq!(usage.short_window.unwrap().remaining_percentage, Some(38.0));
assert_eq!(usage.weekly_window.unwrap().remaining_percentage, Some(62.0));
assert_eq!(
usage.short_window.unwrap().reset_at,
parse_iso8601("2026-10-02T12:22:00Z")
);
}
#[test]
fn claude_missing_limits_do_not_fabricate_zero() {
let error = parse_claude(br#"{"rate_limits_available":true,"rate_limits":null}"#, Utc::now()).unwrap_err();
assert_eq!(error.status, FetchStatus::Failed);
}
#[test]
fn claude_non_subscription_auth() {
let error = parse_claude(br#"{"rate_limits_available":false}"#, Utc::now()).unwrap_err();
assert_eq!(error.status, FetchStatus::NotSignedIn);
}
#[test]
fn claude_invalid_reset_is_an_error() {
let json = br#"{"rate_limits":{"five_hour":{"utilization":42,"resets_at":"tomorrow"}}}"#;
assert!(parse_claude(json, Utc::now()).is_err());
}
#[test]
fn claude_missing_reset_remains_unknown() {
let json = br#"{"rate_limits":{"five_hour":{"utilization":42}}}"#;
let usage = parse_claude(json, Utc::now()).unwrap();
assert_eq!(usage.short_window.unwrap().remaining_percentage, Some(58.0));
assert!(usage.short_window.unwrap().reset_at.is_none());
assert!(usage.weekly_window.is_none());
}
#[test]
fn codex_uses_matching_bucket_and_actual_durations() {
let usage = parse_codex(&fixture("codex"), Utc::now()).unwrap();
assert_eq!(usage.short_window.as_ref().unwrap().remaining_percentage, Some(71.0));
assert_eq!(usage.weekly_window.as_ref().unwrap().remaining_percentage, Some(44.0));
assert_eq!(
usage.short_window.unwrap().reset_at.unwrap().timestamp(),
1_790_941_620
);
}
#[test]
fn codex_reversed_windows() {
let json = br#"{"rateLimits":{"primary":{"usedPercent":42,"windowDurationMins":10080},"secondary":{"usedPercent":7,"windowDurationMins":300}}}"#;
let usage = parse_codex(json, Utc::now()).unwrap();
assert_eq!(usage.short_window.unwrap().used_percentage, Some(7.0));
assert_eq!(usage.weekly_window.unwrap().used_percentage, Some(42.0));
}
#[test]
fn codex_unknown_window_is_not_called_five_hours() {
let json = br#"{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":15}}}"#;
let usage = parse_codex(json, Utc::now()).unwrap();
assert!(usage.short_window.is_none());
assert_eq!(usage.additional_usage[0].name, "15m window");
}
#[test]
fn codex_missing_duration_is_not_guessed() {
let json = br#"{"rateLimits":{"primary":{"usedPercent":25}}}"#;
let usage = parse_codex(json, Utc::now()).unwrap();
assert!(usage.short_window.is_none());
assert_eq!(usage.additional_usage[0].name, "Unspecified window");
}
#[test]
fn codex_wrong_bucket_is_unsupported() {
let json = br#"{"rateLimits":{"limitId":"other","primary":{"usedPercent":25,"windowDurationMins":300}}}"#;
let error = parse_codex(json, Utc::now()).unwrap_err();
assert_eq!(error.status, FetchStatus::Unsupported);
}
#[test]
fn malformed_data_is_rejected() {
assert!(parse_claude(b"broken", Utc::now()).is_err());
assert!(parse_codex(b"broken", Utc::now()).is_err());
}
#[test]
fn cursor_status_is_not_an_allowance() {
let text = String::from_utf8(fixture("cursor")).unwrap();
let metric = subscription_status(&text).unwrap();
assert_eq!(metric.name, "Subscription");
assert_eq!(metric.value, "active");
assert!(subscription_status("unexpected raw value").is_none());
}
#[test]
fn cursor_billing_usage_keeps_reset_label_and_independent_categories() {
let usage = parse_cursor(&fixture("cursor-usage"), Utc::now()).unwrap();
assert_eq!(usage.billing_window.unwrap().remaining_percentage, Some(56.0));
assert!(usage.billing_window.unwrap().reset_at.is_none());
assert!(usage.short_window.is_none());
assert!(usage.weekly_window.is_none());
assert_eq!(usage.billing_reset_label.as_deref(), Some("Oct 30"));
let pools = usage.billing_pools.unwrap();
assert_eq!(pools[0].name, "Cursor models");
assert_eq!(pools[0].window.used_percentage, Some(48.0));
assert_eq!(pools[1].name, "Other models");
assert_eq!(pools[1].window.used_percentage, Some(0.0));
assert_eq!(usage.additional_usage[0].value, "Disabled");
}
#[test]
fn cursor_ansi_and_private_metadata_are_not_persisted() {
let screen = fixture("cursor-usage");
let mut text = b"\x1b]0;private@example.invalid\x07\x1b[2J\x1b[36m".to_vec();
text.extend_from_slice(&screen);
text.extend_from_slice(b"\x1b[0m");
let usage = parse_cursor(&text, Utc::now()).unwrap();
assert_eq!(usage.billing_window.unwrap().used_percentage, Some(44.0));
let encoded = serde_json::to_string(&usage).unwrap();
assert!(!encoded.contains("private"));
}
#[test]
fn cursor_incomplete_changed_and_enterprise_screens_rejected() {
let screen = String::from_utf8(fixture("cursor-usage")).unwrap();
let changed = screen.replace("Included", "New label");
let private_row = screen.replace("On-Demand Disabled", "On-Demand private-account");
for invalid in ["active", "Personal spend for the current billing period", changed.as_str(), private_row.as_str()] {
assert!(parse_cursor(invalid.as_bytes(), Utc::now()).is_err(), "{invalid}");
}
}
#[test]
fn cursor_waits_for_full_on_demand_row_and_accepts_spend_limits() {
let data = String::from_utf8(fixture("cursor-usage")).unwrap();
let prefix = data.find("On-Demand").unwrap();
let partial = format!("{}On-Demand $0.75 ", &data[..prefix]);
assert!(parse_cursor(partial.as_bytes(), Utc::now()).is_err());
let complete = format!("{partial}/ $40.00 █░░░░\n");
let usage = parse_cursor(complete.as_bytes(), Utc::now()).unwrap();
assert_eq!(usage.additional_usage.last().unwrap().value, "$0.75 / $40.00");
}
}
+36
View File
@@ -0,0 +1,36 @@
use std::path::PathBuf;
pub fn home() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"))
}
pub fn config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home().join(".config"))
.join("ai-usage")
}
pub fn config_path() -> PathBuf {
config_dir().join("config.json")
}
pub fn state_dir() -> PathBuf {
std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home().join(".local/state"))
.join("ai-usage")
}
pub fn snapshot_path() -> PathBuf {
state_dir().join("usage.json")
}
pub fn socket_path() -> PathBuf {
let runtime = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(format!("/run/user/{}", unsafe { libc::getuid() })));
runtime.join("ai-usage.sock")
}
+346
View File
@@ -0,0 +1,346 @@
use std::io::Write;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
#[derive(Debug)]
pub struct ProcessResult {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_status: i32,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ProcessError {
Launch,
TimedOut,
Cancelled,
OutputLimit,
Io,
}
#[derive(Debug, PartialEq, Eq)]
pub enum StreamAction {
None,
Send(Vec<u8>),
Finish,
}
pub trait ProcessHooks {
fn on_output(&mut self, chunk: &[u8], at: Duration) -> StreamAction;
fn on_poll(&mut self, at: Duration) -> StreamAction;
}
pub struct CommandSpec<'a> {
pub executable: &'a Path,
pub arguments: &'a [String],
pub environment: &'a [(String, String)],
pub working_directory: Option<&'a Path>,
pub timeout: Duration,
pub terminal: bool,
pub initial_input: &'a [u8],
pub cancel: &'a AtomicBool,
}
struct Reader {
fd: OwnedFd,
open: bool,
is_stdout: bool,
}
pub fn run(spec: CommandSpec<'_>, hooks: Option<&mut dyn ProcessHooks>) -> Result<ProcessResult, ProcessError> {
if spec.cancel.load(Ordering::Relaxed) {
return Err(ProcessError::Cancelled);
}
let mut command = Command::new(spec.executable);
command.args(spec.arguments);
command.env_clear();
for (key, value) in spec.environment {
command.env(key, value);
}
if let Some(directory) = spec.working_directory {
command.current_dir(directory);
}
unsafe {
command.pre_exec(|| {
libc::setpgid(0, 0);
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
Ok(())
});
}
let mut pty_write = None;
let mut readers = Vec::new();
let mut child_stdin = None;
if spec.terminal {
let (master, slave) = open_pty()?;
let stdout = duplicate(&slave)?;
let stderr = duplicate(&slave)?;
command.stdin(Stdio::from(slave));
command.stdout(Stdio::from(stdout));
command.stderr(Stdio::from(stderr));
set_nonblocking(master.as_raw_fd())?;
readers.push(Reader {
fd: master,
open: true,
is_stdout: true,
});
pty_write = Some(readers[0].fd.try_clone().map_err(|_| ProcessError::Io)?);
} else {
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
}
let mut child = command.spawn().map_err(|_| ProcessError::Launch)?;
let pid = child.id() as i32;
unsafe {
libc::setpgid(pid, pid);
}
let _guard = KillGuard { pid };
if !spec.terminal {
child_stdin = child.stdin.take();
if let Some(stdout) = child.stdout.take() {
let fd = OwnedFd::from(stdout);
set_nonblocking(fd.as_raw_fd())?;
readers.push(Reader { fd, open: true, is_stdout: true });
}
if let Some(stderr) = child.stderr.take() {
let fd = OwnedFd::from(stderr);
set_nonblocking(fd.as_raw_fd())?;
readers.push(Reader { fd, open: true, is_stdout: false });
}
}
let origin = Instant::now();
if !spec.initial_input.is_empty() {
write_all(pty_write.as_ref(), child_stdin.as_mut(), spec.initial_input)?;
}
let keep_stdin = hooks.is_some() || spec.terminal;
if !keep_stdin {
child_stdin.take();
}
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut child_exited = false;
let mut drained_after_exit = false;
let mut status_code = 0;
let mut hooks = hooks;
loop {
if spec.cancel.load(Ordering::Relaxed) {
return Err(ProcessError::Cancelled);
}
if origin.elapsed() >= spec.timeout {
return Err(ProcessError::TimedOut);
}
for reader in &mut readers {
if !reader.open {
continue;
}
loop {
if spec.cancel.load(Ordering::Relaxed) {
return Err(ProcessError::Cancelled);
}
let mut buffer = [0u8; 16384];
let count = unsafe { libc::read(reader.fd.as_raw_fd(), buffer.as_mut_ptr().cast(), buffer.len()) };
if count > 0 {
let chunk = &buffer[..count as usize];
if reader.is_stdout {
stdout.extend_from_slice(chunk);
if let Some(hooks) = hooks.as_mut() {
match hooks.on_output(chunk, origin.elapsed()) {
StreamAction::None => {}
StreamAction::Send(input) => {
write_all(pty_write.as_ref(), child_stdin.as_mut(), &input)?;
}
StreamAction::Finish => {
return Ok(ProcessResult { stdout, stderr, exit_status: 0 });
}
}
}
} else {
stderr.extend_from_slice(chunk);
}
if stdout.len() + stderr.len() > 4 * 1024 * 1024 {
return Err(ProcessError::OutputLimit);
}
continue;
}
if count == 0 {
reader.open = false;
break;
}
let error = std::io::Error::last_os_error();
match error.raw_os_error() {
Some(libc::EINTR) => continue,
Some(libc::EAGAIN) => break,
Some(libc::EIO) if spec.terminal => {
reader.open = false;
break;
}
_ => return Err(ProcessError::Io),
}
}
}
if let Some(hooks) = hooks.as_mut() {
match hooks.on_poll(origin.elapsed()) {
StreamAction::None => {}
StreamAction::Send(input) => write_all(pty_write.as_ref(), child_stdin.as_mut(), &input)?,
StreamAction::Finish => return Ok(ProcessResult { stdout, stderr, exit_status: 0 }),
}
}
if !child_exited {
match child.try_wait() {
Ok(Some(status)) => {
child_exited = true;
status_code = status.code().unwrap_or(-1);
unsafe {
libc::kill(-pid, libc::SIGTERM);
libc::kill(-pid, libc::SIGKILL);
}
}
Ok(None) => {}
Err(_) => return Err(ProcessError::Io),
}
}
let output_open = readers.iter().any(|reader| reader.open);
if child_exited && !output_open {
return Ok(ProcessResult {
stdout,
stderr,
exit_status: status_code,
});
}
if child_exited && spec.terminal {
if drained_after_exit {
return Ok(ProcessResult {
stdout,
stderr,
exit_status: status_code,
});
}
drained_after_exit = true;
continue;
}
let wait_ms = if hooks.is_some() { 100 } else { 250 };
let remaining = spec.timeout.saturating_sub(origin.elapsed()).as_millis() as i32;
poll_readers(&readers, wait_ms.min(remaining).max(0));
}
}
fn write_all(
master: Option<&OwnedFd>,
mut stdin: Option<&mut std::process::ChildStdin>,
data: &[u8],
) -> Result<(), ProcessError> {
if data.len() > 4096 {
return Err(ProcessError::Io);
}
let mut offset = 0;
while offset < data.len() {
let written = if let Some(master) = master {
unsafe { libc::write(master.as_raw_fd(), data[offset..].as_ptr().cast(), data.len() - offset) }
} else if let Some(stdin) = stdin.as_mut() {
match stdin.write(&data[offset..]) {
Ok(count) => count as isize,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => return Err(ProcessError::Io),
}
} else {
return Err(ProcessError::Io);
};
if written < 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(libc::EINTR) {
continue;
}
return Err(ProcessError::Io);
}
offset += written as usize;
}
Ok(())
}
fn poll_readers(readers: &[Reader], millis: i32) {
let mut fds: Vec<libc::pollfd> = readers
.iter()
.filter(|reader| reader.open)
.map(|reader| libc::pollfd {
fd: reader.fd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
})
.collect();
if fds.is_empty() {
std::thread::sleep(Duration::from_millis(millis.max(0) as u64));
return;
}
unsafe {
libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, millis);
}
}
fn open_pty() -> Result<(OwnedFd, OwnedFd), ProcessError> {
let mut master = -1;
let mut slave = -1;
let mut size = libc::winsize {
ws_row: 50,
ws_col: 120,
ws_xpixel: 0,
ws_ypixel: 0,
};
let result = unsafe { libc::openpty(&mut master, &mut slave, std::ptr::null_mut(), std::ptr::null(), &mut size) };
if result != 0 {
return Err(ProcessError::Io);
}
unsafe { Ok((OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave))) }
}
fn duplicate(fd: &OwnedFd) -> Result<OwnedFd, ProcessError> {
let duplicated = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if duplicated < 0 {
Err(ProcessError::Io)
} else {
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
}
}
fn set_nonblocking(fd: i32) -> Result<(), ProcessError> {
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
return Err(ProcessError::Io);
}
Ok(())
}
struct KillGuard {
pid: i32,
}
impl Drop for KillGuard {
fn drop(&mut self) {
unsafe {
libc::kill(-self.pid, libc::SIGTERM);
}
let deadline = Instant::now() + Duration::from_millis(500);
let mut status = 0;
let mut reaped = false;
while Instant::now() < deadline {
let result = unsafe { libc::waitpid(self.pid, &mut status, libc::WNOHANG) };
if result == self.pid {
reaped = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
unsafe {
libc::kill(-self.pid, libc::SIGKILL);
if !reaped {
libc::waitpid(self.pid, &mut status, 0);
}
}
}
}
+415
View File
@@ -0,0 +1,415 @@
use std::time::Duration;
use serde_json::{json, Value};
use crate::model::{FetchStatus, ProviderFailure, ProviderUsage};
use crate::parsers::{self, parse_cursor};
use crate::process::StreamAction;
pub struct UsageProtocolSession {
kind: ProtocolKind,
pending: Vec<u8>,
usage_sent: bool,
pub usage_data: Option<Vec<u8>>,
pub failure: Option<ProviderFailure>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ProtocolKind {
Claude,
Codex,
}
impl UsageProtocolSession {
pub fn new(kind: ProtocolKind) -> Self {
Self {
kind,
pending: Vec::new(),
usage_sent: false,
usage_data: None,
failure: None,
}
}
pub fn initialize() -> Vec<u8> {
line(&json!({
"type": "control_request",
"request_id": "ai-usage-init",
"request": { "subtype": "initialize" }
}))
}
pub fn codex_initialize() -> Vec<u8> {
line(&json!({
"id": 1,
"method": "initialize",
"params": {
"clientInfo": {
"name": "ai_usage_monitor",
"title": "AI Usage Monitor",
"version": "1.0"
}
}
}))
}
pub fn consume(&mut self, chunk: &[u8]) -> StreamAction {
self.pending.extend_from_slice(chunk);
while let Some(index) = self.pending.iter().position(|byte| *byte == b'\n') {
let raw = self.pending.drain(..=index).collect::<Vec<_>>();
let bytes = &raw[..raw.len().saturating_sub(1)];
let Ok(message) = serde_json::from_slice::<Value>(bytes) else {
continue;
};
match self.kind {
ProtocolKind::Codex => {
let Some(id) = message.get("id").and_then(Value::as_i64) else {
continue;
};
if id != 1 && id != 2 {
continue;
}
if message.get("error").is_some() {
self.failure = Some(ProviderFailure::new(
FetchStatus::Failed,
"Codex rejected the usage request. Check its existing subscription login.",
));
return StreamAction::Finish;
}
if id == 1 && !self.usage_sent {
self.usage_sent = true;
let mut next = line_value(&json!({"method": "initialized", "params": {}}));
next.extend(line_value(&json!({
"id": 2,
"method": "account/rateLimits/read",
"params": { "excludeResetCreditDetails": true }
})));
return StreamAction::Send(next);
}
if id == 2 {
if let Some(result) = message.get("result").and_then(Value::as_object) {
let mut allowed = serde_json::Map::new();
for key in ["rateLimits", "rateLimitsByLimitId"] {
if let Some(value) = result.get(key) {
allowed.insert(key.to_string(), value.clone());
}
}
self.usage_data = serde_json::to_vec(&Value::Object(allowed)).ok();
return StreamAction::Finish;
}
}
}
ProtocolKind::Claude => {
if message.get("type").and_then(Value::as_str) != Some("control_response") {
continue;
}
let Some(response) = message.get("response").and_then(Value::as_object) else {
continue;
};
let Some(id) = response.get("request_id").and_then(Value::as_str) else {
continue;
};
if id != "ai-usage-init" && id != "ai-usage-read" {
continue;
}
if response.get("subtype").and_then(Value::as_str) != Some("success") {
self.failure = Some(ProviderFailure::new(
FetchStatus::Unsupported,
"Claude's get_usage request is unsupported or failed. Open Claude to check its existing login.",
));
return StreamAction::Finish;
}
if id == "ai-usage-init" && !self.usage_sent {
self.usage_sent = true;
return StreamAction::Send(line(&json!({
"type": "control_request",
"request_id": "ai-usage-read",
"request": { "subtype": "get_usage", "skip_behaviors": true }
})));
}
if id == "ai-usage-read" {
if let Some(result) = response.get("response").and_then(Value::as_object) {
let mut allowed = serde_json::Map::new();
for key in ["rate_limits_available", "rate_limits"] {
if let Some(value) = result.get(key) {
allowed.insert(key.to_string(), value.clone());
}
}
self.usage_data = serde_json::to_vec(&Value::Object(allowed)).ok();
return StreamAction::Finish;
}
}
}
}
}
StreamAction::None
}
}
fn line(value: &Value) -> Vec<u8> {
line_value(value)
}
fn line_value(value: &Value) -> Vec<u8> {
let mut data = serde_json::to_vec(value).unwrap_or_default();
data.push(b'\n');
data
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CursorStage {
Startup,
Trusting,
SelectingUsage,
Submitted,
}
pub struct CursorUsageSession {
stage: CursorStage,
transcript: Vec<u8>,
pending_input: Option<Vec<u8>>,
last_output_at: Duration,
workspace: String,
workspace_canonical: String,
pub usage: Option<ProviderUsage>,
pub failure: Option<ProviderFailure>,
}
impl CursorUsageSession {
pub fn new(workspace: impl Into<String>) -> Self {
let workspace = workspace.into();
let workspace_canonical = std::fs::canonicalize(&workspace)
.map(|path| path.display().to_string())
.unwrap_or_else(|_| workspace.clone());
Self {
stage: CursorStage::Startup,
transcript: Vec::new(),
pending_input: None,
last_output_at: Duration::ZERO,
workspace,
workspace_canonical,
usage: None,
failure: None,
}
}
pub fn consume(&mut self, chunk: &[u8], at: Duration) -> StreamAction {
self.last_output_at = at;
self.transcript.extend_from_slice(chunk);
if self.transcript.len() > 1024 * 1024 {
self.failure = Some(ProviderFailure::new(
FetchStatus::Failed,
"Cursor produced too much terminal output.",
));
return StreamAction::Finish;
}
if self.pending_input.is_some() {
return StreamAction::None;
}
let text = parsers::terminal_text(&self.transcript);
if text.contains("Not logged in")
|| text.contains("Authentication required")
|| text.contains("Sign in to Cursor")
{
self.failure = Some(ProviderFailure::new(
FetchStatus::NotSignedIn,
"Sign in using Cursor Agent CLI (agent login), then refresh.",
));
return StreamAction::Finish;
}
match self.stage {
CursorStage::Startup | CursorStage::Trusting => {
if self.stage == CursorStage::Startup && text.contains("Workspace Trust Required") {
if !text.contains("Trust this workspace") {
return StreamAction::None;
}
if !text.contains(&self.workspace) && !text.contains(&self.workspace_canonical) {
self.failure = Some(ProviderFailure::new(
FetchStatus::Failed,
"Cursor requested trust for an unexpected workspace.",
));
return StreamAction::Finish;
}
self.stage = CursorStage::Trusting;
self.transcript.clear();
self.pending_input = Some(b"a".to_vec());
return StreamAction::None;
}
if text.contains("Plan, search, build anything") {
self.stage = CursorStage::SelectingUsage;
self.transcript.clear();
self.pending_input = Some(b"/usage".to_vec());
return StreamAction::None;
}
}
CursorStage::SelectingUsage => {
if text.contains("/usage") && text.contains("Show plan and on-demand usage") {
self.stage = CursorStage::Submitted;
self.transcript.clear();
self.pending_input = Some(b"\r".to_vec());
return StreamAction::None;
}
}
CursorStage::Submitted => {
if text.contains("Failed to load usage data") {
self.failure = Some(ProviderFailure::new(
FetchStatus::Failed,
"Cursor could not load usage. Check its existing login and network connection.",
));
return StreamAction::Finish;
}
if text.contains("Usage details are not available")
|| text.contains("Personal spend for the current billing period")
{
self.failure = Some(ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor did not expose an included allowance for this plan in the CLI.",
));
return StreamAction::Finish;
}
if let Ok(parsed) = parse_cursor(&self.transcript, chrono::Utc::now()) {
self.usage = Some(parsed);
return StreamAction::Finish;
}
}
}
StreamAction::None
}
pub fn poll(&mut self, at: Duration) -> StreamAction {
let Some(input) = &self.pending_input else {
return StreamAction::None;
};
if at.saturating_sub(self.last_output_at) < Duration::from_millis(300) {
return StreamAction::None;
}
let input = input.clone();
self.pending_input = None;
StreamAction::Send(input)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parsers::parse_claude;
use crate::parsers::parse_codex;
#[test]
fn chunked_codex_protocol_ignores_notifications_and_metadata() {
let mut session = UsageProtocolSession::new(ProtocolKind::Codex);
let notification = line(&json!({"method": "unrelated/updated", "params": {"private": "ignored"}}));
assert!(matches!(session.consume(&notification), StreamAction::None));
let init_reply = line(&json!({"id": 1, "result": {}}));
assert!(matches!(session.consume(&init_reply[..4]), StreamAction::None));
let StreamAction::Send(command) = session.consume(&init_reply[4..]) else {
panic!("no usage request");
};
let methods: Vec<String> = command
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(|line| {
serde_json::from_slice::<Value>(line)
.unwrap()
.get("method")
.and_then(Value::as_str)
.unwrap()
.to_string()
})
.collect();
assert_eq!(methods, ["initialized", "account/rateLimits/read"]);
let payload: Value = serde_json::from_slice(include_bytes!("../fixtures/codex.json")).unwrap();
assert!(matches!(
session.consume(&line(&json!({"id": 2, "result": payload}))),
StreamAction::Finish
));
let usage = parse_codex(session.usage_data.as_ref().unwrap(), chrono::Utc::now()).unwrap();
assert_eq!(usage.short_window.unwrap().remaining_percentage, Some(71.0));
}
#[test]
fn claude_protocol_discards_session_and_behavior_data() {
let mut session = UsageProtocolSession::new(ProtocolKind::Claude);
session.consume(&line(&json!({
"type": "control_response",
"response": {"request_id": "ai-usage-init", "subtype": "success", "response": {}}
})));
let mut payload: Value = serde_json::from_slice(include_bytes!("../fixtures/claude.json")).unwrap();
payload["session"] = json!({"private": "never save"});
payload["behaviors"] = json!({"private": "never save"});
session.consume(&line(&json!({
"type": "control_response",
"response": {"request_id": "ai-usage-read", "subtype": "success", "response": payload}
})));
let data = session.usage_data.unwrap();
let text = String::from_utf8(data.clone()).unwrap();
assert!(!text.contains("never save"));
assert_eq!(
parse_claude(&data, chrono::Utc::now()).unwrap().short_window.unwrap().remaining_percentage,
Some(38.0)
);
}
#[test]
fn remote_errors_are_not_copied_to_state() {
let mut session = UsageProtocolSession::new(ProtocolKind::Codex);
session.consume(&line(&json!({"id": 2, "error": {"message": "private-account-or-token"}})));
let message = session.failure.unwrap().message;
assert!(!message.contains("private-account-or-token"));
}
#[test]
fn cursor_session_waits_for_menu_and_chunked_complete_usage() {
let mut session = CursorUsageSession::new("/tmp/monitor-fixture");
assert!(matches!(
session.consume(b"Workspace Trust Required", Duration::ZERO),
StreamAction::None
));
session.consume(b"\n/tmp/monitor-fixture\nTrust this workspace", Duration::ZERO);
assert!(matches!(session.poll(Duration::from_millis(100)), StreamAction::None));
match session.poll(Duration::from_secs(1)) {
StreamAction::Send(trust) => assert_eq!(trust, b"a"),
other => panic!("no trust key: {other:?}"),
}
session.consume(b"Plan, search, build anything", Duration::from_secs(2));
match session.poll(Duration::from_secs(3)) {
StreamAction::Send(command) => assert_eq!(command, b"/usage"),
other => panic!("no usage command: {other:?}"),
}
assert!(matches!(
session.consume(b"-> /usage", Duration::from_secs(4)),
StreamAction::None
));
session.consume(b" Show plan and on-demand usage", Duration::from_secs(4));
match session.poll(Duration::from_secs(5)) {
StreamAction::Send(enter) => assert_eq!(enter, b"\r"),
other => panic!("did not accept usage command: {other:?}"),
}
let data = include_bytes!("../fixtures/cursor-usage.txt");
for byte in data.iter().take(data.len() / 2).copied() {
session.consume(&[byte], Duration::from_secs(6));
}
assert!(session.usage.is_none());
assert!(matches!(
session.consume(&data[data.len() / 2..], Duration::from_secs(6)),
StreamAction::Finish
));
assert_eq!(session.usage.unwrap().billing_window.unwrap().remaining_percentage, Some(56.0));
}
#[test]
fn cursor_session_does_not_trust_unexpected_workspace() {
let mut session = CursorUsageSession::new("/tmp/monitor-fixture");
assert!(matches!(
session.consume(
b"Workspace Trust Required\n/other/project\nTrust this workspace",
Duration::ZERO
),
StreamAction::Finish
));
assert!(session.usage.is_none());
let failure = session.failure.unwrap();
assert_eq!(failure.status, FetchStatus::Failed);
assert!(!failure.message.contains("/other/project"));
}
}
+459
View File
@@ -0,0 +1,459 @@
use std::fs;
use std::os::unix::fs::DirBuilderExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use chrono::Utc;
use serde_json::Value;
use crate::discovery;
use crate::model::{FetchStatus, Provider, ProviderFailure, ProviderUsage, UsageMetric};
use crate::parsers::{self, parse_claude, parse_codex};
use crate::process::{self, CommandSpec, ProcessError, ProcessHooks, StreamAction};
use crate::protocol::{CursorUsageSession, ProtocolKind, UsageProtocolSession};
const GUARD: &str = include_str!("cursor-guard.js");
const ALLOWED_ENV: &[&str] = &[
"HOME",
"PATH",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TMPDIR",
"USER",
"LOGNAME",
"CODEX_HOME",
"CLAUDE_CONFIG_DIR",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NIX_SSL_CERT_FILE",
"XDG_RUNTIME_DIR",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_CACHE_HOME",
"DBUS_SESSION_BUS_ADDRESS",
];
pub fn fetch(
provider: Provider,
override_path: Option<&str>,
cancel: &AtomicBool,
) -> Result<ProviderUsage, ProviderFailure> {
let home = home_dir();
let path = std::env::var("PATH").unwrap_or_default();
let executable = discovery::candidates(provider, override_path, &home, &path)
.into_iter()
.next();
match provider {
Provider::Claude => fetch_claude(executable.as_ref().map(|item| item.path.as_path()), cancel),
Provider::Codex => fetch_codex(executable.as_ref().map(|item| item.path.as_path()), cancel),
Provider::Cursor => fetch_cursor(executable.as_ref().map(|item| item.path.as_path()), &home, cancel),
}
}
fn fetch_claude(executable: Option<&Path>, cancel: &AtomicBool) -> Result<ProviderUsage, ProviderFailure> {
let Some(executable) = executable else {
return Err(ProviderFailure::new(
FetchStatus::Unsupported,
"Claude executable not found. Set its path in Settings.",
));
};
let mut session = UsageProtocolSession::new(ProtocolKind::Claude);
let mut environment = local_environment();
for name in [
"DISABLE_TELEMETRY",
"DISABLE_ERROR_REPORTING",
"DISABLE_GROWTHBOOK",
"DISABLE_AUTOUPDATER",
"DISABLE_UPDATES",
"DISABLE_FEEDBACK_COMMAND",
"DISABLE_BUG_COMMAND",
"CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY",
"CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL",
] {
environment.push((name.to_string(), "1".to_string()));
}
environment.push(("CLAUDE_CODE_ENABLE_TELEMETRY".to_string(), "0".to_string()));
environment.push(("OTEL_METRICS_EXPORTER".to_string(), "none".to_string()));
environment.push(("OTEL_LOGS_EXPORTER".to_string(), "none".to_string()));
environment.push(("OTEL_TRACES_EXPORTER".to_string(), "none".to_string()));
let arguments = strings(&[
"--print",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--verbose",
"--no-session-persistence",
"--safe-mode",
"--setting-sources",
"",
"--strict-mcp-config",
"--mcp-config",
"{\"mcpServers\":{}}",
"--tools",
"",
]);
let initial = UsageProtocolSession::initialize();
let directory = std::env::temp_dir();
let mut hooks = ProtocolHooks { session: &mut session };
let result = process::run(
CommandSpec {
executable,
arguments: &arguments,
environment: &environment,
working_directory: Some(&directory),
timeout: Duration::from_secs(30),
terminal: false,
initial_input: &initial,
cancel,
},
Some(&mut hooks),
);
map_process(result)?;
if let Some(failure) = session.failure {
return Err(failure);
}
let data = session.usage_data.ok_or_else(|| {
ProviderFailure::new(
FetchStatus::Unsupported,
"Claude did not return a structured usage response.",
)
})?;
parse_claude(&data, Utc::now())
}
fn fetch_codex(executable: Option<&Path>, cancel: &AtomicBool) -> Result<ProviderUsage, ProviderFailure> {
let Some(executable) = executable else {
return Err(ProviderFailure::new(
FetchStatus::Unsupported,
"Codex executable not found. Set its path in Settings.",
));
};
let mut session = UsageProtocolSession::new(ProtocolKind::Codex);
let environment = local_environment();
let arguments = strings(&[
"-c",
"analytics.enabled=false",
"-c",
"otel.exporter=\"none\"",
"-c",
"otel.trace_exporter=\"none\"",
"app-server",
"--listen",
"stdio://",
]);
let initial = UsageProtocolSession::codex_initialize();
let directory = std::env::temp_dir();
let mut hooks = ProtocolHooks { session: &mut session };
let result = process::run(
CommandSpec {
executable,
arguments: &arguments,
environment: &environment,
working_directory: Some(&directory),
timeout: Duration::from_secs(30),
terminal: false,
initial_input: &initial,
cancel,
},
Some(&mut hooks),
);
map_process(result)?;
if let Some(failure) = session.failure {
return Err(failure);
}
let data = session.usage_data.ok_or_else(|| {
ProviderFailure::new(FetchStatus::Unsupported, "Codex did not return a rate-limit response.")
})?;
parse_codex(&data, Utc::now())
}
fn fetch_cursor(executable: Option<&Path>, home: &Path, cancel: &AtomicBool) -> Result<ProviderUsage, ProviderFailure> {
if let Some(executable) = executable {
return fetch_cursor_cli(executable, cancel);
}
fetch_subscription_status(home, cancel)
}
fn fetch_cursor_cli(executable: &Path, cancel: &AtomicBool) -> Result<ProviderUsage, ProviderFailure> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let root = std::env::temp_dir().join(format!("ai-usage-cursor-{}-{nanos}", std::process::id()));
let workspace = root.join("workspace");
let config = root.join("config");
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&workspace)
.and_then(|_| fs::create_dir_all(&config))
.map_err(|_| {
ProviderFailure::new(
FetchStatus::Failed,
"Cursor's temporary usage workspace could not be created.",
)
})?;
let _cleanup = TempDir(root.clone());
let guard = root.join("usage-only.cjs");
fs::write(&guard, GUARD).map_err(|_| {
ProviderFailure::new(
FetchStatus::Failed,
"Cursor's usage-only process configuration could not be created.",
)
})?;
let mut environment = local_environment();
environment.push(("NODE_OPTIONS".to_string(), format!("--require \"{}\"", guard.display())));
environment.push(("CURSOR_CONFIG_DIR".to_string(), config.display().to_string()));
environment.push(("CURSOR_DATA_DIR".to_string(), root.join("data").display().to_string()));
environment.push((
"NODE_COMPILE_CACHE".to_string(),
root.join("compile-cache").display().to_string(),
));
environment.push(("TERM".to_string(), "xterm-256color".to_string()));
environment.push(("LANG".to_string(), "en_US.UTF-8".to_string()));
environment.push(("LC_ALL".to_string(), "en_US.UTF-8".to_string()));
environment.push(("NO_COLOR".to_string(), "1".to_string()));
environment.push(("NO_OPEN_BROWSER".to_string(), "1".to_string()));
environment.push(("OTEL_TRACES_SAMPLER".to_string(), "always_off".to_string()));
let common = [
"--disable-auto-update",
"--disable-project-configs",
"--disable-indexing",
"--disable-codebase-ref",
"--exclude-workspace-context",
];
let mut status_environment = environment.clone();
status_environment.retain(|(key, _)| key != "CURSOR_CONFIG_DIR");
let status_arguments = strings(&{
let mut args = common.to_vec();
args.extend(["status", "--format", "json"]);
args
});
let status = process::run(
CommandSpec {
executable,
arguments: &status_arguments,
environment: &status_environment,
working_directory: Some(&workspace),
timeout: Duration::from_secs(15),
terminal: false,
initial_input: &[],
cancel,
},
None,
)
.map_err(process_failure)?;
let login: Value = serde_json::from_slice(&status.stdout).map_err(|_| {
ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor Agent CLI did not return its authentication status. Check its path and version.",
)
})?;
let signed_in = login.get("isAuthenticated").and_then(Value::as_bool).ok_or_else(|| {
ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor Agent CLI did not return its authentication status. Check its path and version.",
)
})?;
if status.exit_status != 0 || !signed_in {
return Err(ProviderFailure::new(
FetchStatus::NotSignedIn,
"Sign in using Cursor Agent CLI (agent login), then refresh.",
));
}
let workspace_text = workspace.display().to_string();
let mut argument_strings: Vec<String> = common.iter().map(|item| (*item).to_string()).collect();
argument_strings.push("--workspace".to_string());
argument_strings.push(workspace_text.clone());
if let Some(team) = login
.get("userInfo")
.and_then(|info| info.get("teamId"))
.and_then(|value| value.as_i64().or_else(|| value.as_str().and_then(|text| text.parse().ok())))
{
if team > 0 {
argument_strings.push("--header".to_string());
argument_strings.push(format!("x-cursor-team-id: {team}"));
}
}
let mut session = CursorUsageSession::new(workspace_text);
let mut hooks = CursorHooks { session: &mut session };
let result = process::run(
CommandSpec {
executable,
arguments: &argument_strings,
environment: &environment,
working_directory: Some(&workspace),
timeout: Duration::from_secs(30),
terminal: true,
initial_input: &[],
cancel,
},
Some(&mut hooks),
);
map_process(result)?;
if let Some(failure) = session.failure {
return Err(failure);
}
session.usage.ok_or_else(|| {
ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor Agent CLI did not return a recognized /usage screen. Check its version.",
)
})
}
fn fetch_subscription_status(home: &Path, cancel: &AtomicBool) -> Result<ProviderUsage, ProviderFailure> {
let database = home.join(".config/Cursor/User/globalStorage/state.vscdb");
if !database.exists() {
return Err(ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor's local database is unavailable; no reliable usage source was found.",
));
}
let sqlite = which("sqlite3").ok_or_else(|| {
ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor's local database is unavailable; no reliable usage source was found.",
)
})?;
let arguments = strings(&[
"-readonly",
"-cmd",
".timeout 1000",
&database.display().to_string(),
"SELECT value FROM ItemTable WHERE key = 'cursorAuth/stripeSubscriptionStatus';",
]);
let result = process::run(
CommandSpec {
executable: &sqlite,
arguments: &arguments,
environment: &local_environment(),
working_directory: None,
timeout: Duration::from_secs(5),
terminal: false,
initial_input: &[],
cancel,
},
None,
)
.map_err(process_failure)?;
let text = String::from_utf8_lossy(&result.stdout);
let metric = (result.exit_status == 0)
.then(|| parsers::subscription_status(&text))
.flatten()
.ok_or_else(|| {
ProviderFailure::new(
FetchStatus::Unsupported,
"Cursor exposes no recognized local subscription status or usage allowance.",
)
})?;
Ok(ProviderUsage {
provider: Provider::Cursor,
fetched_at: Utc::now(),
short_window: None,
weekly_window: None,
billing_window: None,
billing_reset_label: None,
billing_pools: None,
additional_usage: vec![
metric,
UsageMetric {
name: "Usage".to_string(),
value: "Install Cursor Agent CLI".to_string(),
},
],
})
}
struct ProtocolHooks<'a> {
session: &'a mut UsageProtocolSession,
}
impl ProcessHooks for ProtocolHooks<'_> {
fn on_output(&mut self, chunk: &[u8], _at: Duration) -> StreamAction {
self.session.consume(chunk)
}
fn on_poll(&mut self, _at: Duration) -> StreamAction {
StreamAction::None
}
}
struct CursorHooks<'a> {
session: &'a mut CursorUsageSession,
}
impl ProcessHooks for CursorHooks<'_> {
fn on_output(&mut self, chunk: &[u8], at: Duration) -> StreamAction {
self.session.consume(chunk, at)
}
fn on_poll(&mut self, at: Duration) -> StreamAction {
self.session.poll(at)
}
}
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn map_process(result: Result<process::ProcessResult, ProcessError>) -> Result<(), ProviderFailure> {
result.map(|_| ()).map_err(process_failure)
}
fn process_failure(error: ProcessError) -> ProviderFailure {
match error {
ProcessError::TimedOut => ProviderFailure::new(
FetchStatus::Failed,
"The provider timed out. Last successful usage is retained.",
),
ProcessError::Cancelled => ProviderFailure::new(FetchStatus::Failed, "Update cancelled."),
ProcessError::Launch => ProviderFailure::new(
FetchStatus::Failed,
"The provider executable could not start. Check its path.",
),
ProcessError::OutputLimit | ProcessError::Io => ProviderFailure::new(
FetchStatus::Failed,
"The provider process failed. Check its path and existing login.",
),
}
}
fn local_environment() -> Vec<(String, String)> {
let mut values: Vec<(String, String)> = std::env::vars()
.filter(|(key, _)| ALLOWED_ENV.contains(&key.as_str()))
.collect();
values.sort();
if !values.iter().any(|(key, _)| key == "SSL_CERT_FILE") {
let bundle = Path::new("/etc/ssl/certs/ca-certificates.crt");
if bundle.exists() {
values.push(("SSL_CERT_FILE".to_string(), bundle.display().to_string()));
}
}
values
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn which(name: &str) -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path).find_map(|directory| {
let candidate = directory.join(name);
candidate.is_file().then_some(candidate)
})
})
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"))
}
+312
View File
@@ -0,0 +1,312 @@
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use crate::model::{FetchStatus, Provider, ProviderFailure, ProviderUsage, UsageSnapshot};
use crate::settings::Settings;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
Startup,
Timer,
Manual,
Resume,
Settings,
}
pub struct Engine {
pub snapshot: UsageSnapshot,
pub settings: Settings,
generation: u64,
forced: HashSet<Provider>,
}
pub struct Round {
generation: u64,
pub providers: Vec<Provider>,
attempted: DateTime<Utc>,
forced: HashSet<Provider>,
}
impl Engine {
pub fn new(snapshot: UsageSnapshot, settings: Settings) -> Self {
let mut engine = Self {
snapshot,
settings,
generation: 0,
forced: HashSet::new(),
};
engine.sync_visibility();
engine
}
pub fn apply_settings(&mut self, updated: Settings) {
let old = self.settings.clone();
if old == updated {
return;
}
self.settings = updated;
for provider in Provider::ALL {
let path_changed = old.override_for(provider) != self.settings.override_for(provider);
let newly_enabled = self.settings.enabled(provider) && !old.enabled(provider);
if self.settings.enabled(provider) && (path_changed || newly_enabled) {
self.forced.insert(provider);
}
if !self.settings.enabled(provider) {
self.forced.remove(&provider);
}
}
self.sync_visibility();
let selection_changed = old.enabled_providers != self.settings.enabled_providers
|| Provider::ALL.iter().any(|provider| old.override_for(*provider) != self.settings.override_for(*provider));
if selection_changed {
self.generation = self.generation.wrapping_add(1);
}
}
pub fn due(&self, now: DateTime<Utc>) -> Vec<Provider> {
Provider::ALL
.into_iter()
.filter(|provider| self.settings.enabled(*provider))
.filter(|provider| {
let elapsed = now.signed_duration_since(self.last_attempt(*provider));
elapsed.num_seconds() >= i64::from(self.settings.refresh_minutes) * 60
})
.collect()
}
pub fn select(&mut self, reason: Reason, now: DateTime<Utc>) -> Option<Round> {
let mut selected: Vec<Provider> = if reason == Reason::Manual {
self.settings.enabled_providers.clone()
} else {
let mut providers = self.due(now);
for provider in &self.forced {
if self.settings.enabled(*provider) && !providers.contains(provider) {
providers.push(*provider);
}
}
providers
};
selected.retain(|provider| self.settings.enabled(*provider));
if selected.is_empty() {
return None;
}
let forced = self
.forced
.iter()
.copied()
.filter(|provider| selected.contains(provider))
.collect::<HashSet<_>>();
for provider in &selected {
self.forced.remove(provider);
}
let generation = self.generation;
Some(Round {
generation,
providers: selected,
attempted: now,
forced,
})
}
pub fn commit(
&mut self,
round: Round,
results: HashMap<Provider, Result<ProviderUsage, ProviderFailure>>,
cancelled: bool,
) -> bool {
if cancelled || round.generation != self.generation {
for provider in round.forced {
if self.settings.enabled(provider) {
self.forced.insert(provider);
}
}
return false;
}
for provider in round.providers {
if !self.settings.enabled(provider) {
continue;
}
let previous = self.snapshot.state(provider).last_successful_usage.clone();
let state = self.snapshot.state_mut(provider);
state.last_attempt_at = Some(round.attempted);
match results.get(&provider) {
Some(Ok(usage)) => {
state.last_successful_usage = Some(usage.clone());
state.status = FetchStatus::Available;
state.message = None;
}
Some(Err(failure)) if failure.message == "Update cancelled." => {
state.last_successful_usage = previous;
}
Some(Err(failure)) => {
state.last_successful_usage = previous;
state.status = failure.status;
state.message = Some(failure.message.clone());
}
None => {
state.last_successful_usage = previous;
state.status = FetchStatus::Failed;
state.message = Some("Provider update failed. Check the app diagnostics.".to_string());
}
}
}
self.snapshot.written_at = Utc::now();
self.snapshot.show_used_percentage = self.settings.show_used_percentage;
self.snapshot.is_demo = false;
true
}
pub fn next_delay(&self, now: DateTime<Utc>) -> Option<chrono::Duration> {
self.settings
.enabled_providers
.iter()
.map(|provider| {
self.last_attempt(*provider) + chrono::Duration::seconds(i64::from(self.settings.refresh_minutes) * 60)
})
.min()
.map(|next| {
let delay = next.signed_duration_since(now);
if delay > chrono::Duration::milliseconds(10) {
delay
} else {
chrono::Duration::milliseconds(10)
}
})
}
fn sync_visibility(&mut self) {
for provider in Provider::ALL {
if !self.settings.enabled(provider) {
let state = self.snapshot.state_mut(provider);
state.status = FetchStatus::Disabled;
state.message = None;
} else if self.snapshot.state(provider).status == FetchStatus::Disabled {
let state = self.snapshot.state_mut(provider);
state.status = if state.last_successful_usage.is_none() {
FetchStatus::Pending
} else {
FetchStatus::Available
};
}
}
self.snapshot.show_used_percentage = self.settings.show_used_percentage;
}
fn last_attempt(&self, provider: Provider) -> DateTime<Utc> {
let state = self.snapshot.state(provider);
state
.last_attempt_at
.or(state.last_successful_usage.as_ref().map(|usage| usage.fetched_at))
.unwrap_or_else(|| DateTime::from_timestamp(0, 0).expect("epoch"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{FetchStatus, ProviderState, ProviderUsage, UsageWindow};
use crate::settings::Settings;
fn at(seconds: i64) -> DateTime<Utc> {
DateTime::from_timestamp(seconds, 0).unwrap()
}
fn cached(when: DateTime<Utc>) -> UsageSnapshot {
UsageSnapshot {
schema_version: 1,
written_at: when,
show_used_percentage: false,
is_demo: false,
providers: Provider::ALL
.into_iter()
.map(|provider| ProviderState {
provider,
last_successful_usage: Some(ProviderUsage {
provider,
fetched_at: when,
short_window: Some(UsageWindow::new(Some(12.0), None, None)),
weekly_window: None,
billing_window: None,
billing_reset_label: None,
billing_pools: None,
additional_usage: Vec::new(),
}),
last_attempt_at: Some(when),
status: FetchStatus::Available,
message: None,
})
.collect(),
}
}
#[test]
fn recent_cache_is_not_due_until_the_interval() {
let snapshot = cached(at(1_800_000_000));
let mut settings = Settings::default();
settings.enabled_providers = vec![Provider::Codex];
let engine = Engine::new(snapshot, settings);
assert!(engine.due(at(1_800_000_000 + 60)).is_empty());
assert_eq!(engine.due(at(1_800_000_000 + 5 * 60)), vec![Provider::Codex]);
}
#[test]
fn manual_selects_every_enabled_provider() {
let mut engine = Engine::new(cached(at(1_800_000_000)), Settings::default());
let round = engine.select(Reason::Manual, at(1_800_000_100)).unwrap();
assert_eq!(round.providers.len(), 3);
}
#[test]
fn failure_keeps_the_previous_success() {
let when = at(1_800_000_000);
let mut engine = Engine::new(cached(when), Settings::default());
let round = engine.select(Reason::Manual, when).unwrap();
let mut results = HashMap::new();
results.insert(
Provider::Claude,
Err(ProviderFailure::new(FetchStatus::Failed, "Fixture failure")),
);
assert!(engine.commit(round, results, false));
let state = engine.snapshot.state(Provider::Claude);
assert_eq!(state.status, FetchStatus::Failed);
assert_eq!(state.message.as_deref(), Some("Fixture failure"));
assert_eq!(state.last_successful_usage.unwrap().fetched_at, when);
}
#[test]
fn cancelled_round_does_not_change_the_cache() {
let when = at(1_800_000_000);
let mut engine = Engine::new(cached(when), Settings::default());
let before = engine.snapshot.clone();
let round = engine.select(Reason::Timer, when + chrono::Duration::hours(1)).unwrap();
let mut results = HashMap::new();
results.insert(
Provider::Codex,
Ok(ProviderUsage {
provider: Provider::Codex,
fetched_at: when + chrono::Duration::hours(1),
short_window: Some(UsageWindow::new(Some(90.0), None, None)),
weekly_window: None,
billing_window: None,
billing_reset_label: None,
billing_pools: None,
additional_usage: Vec::new(),
}),
);
assert!(!engine.commit(round, results, true));
assert_eq!(engine.snapshot.state(Provider::Codex).last_successful_usage, before.state(Provider::Codex).last_successful_usage);
}
#[test]
fn enabling_a_provider_forces_a_refresh() {
let mut settings = Settings::default();
settings.enabled_providers = vec![Provider::Codex];
let mut engine = Engine::new(cached(Utc::now()), settings);
let mut updated = Settings::default();
updated.enabled_providers = vec![Provider::Codex, Provider::Claude];
engine.apply_settings(updated);
assert!(engine.forced.contains(&Provider::Claude));
assert_eq!(engine.snapshot.state(Provider::Cursor).status, FetchStatus::Disabled);
}
}
+88
View File
@@ -0,0 +1,88 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::model::Provider;
use crate::paths;
pub const INTERVALS: [u32; 4] = [5, 15, 30, 60];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
pub refresh_minutes: u32,
pub enabled_providers: Vec<Provider>,
#[serde(default)]
pub executable_overrides: HashMap<Provider, String>,
pub show_used_percentage: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
refresh_minutes: 5,
enabled_providers: Provider::ALL.to_vec(),
executable_overrides: HashMap::new(),
show_used_percentage: false,
}
}
}
impl Settings {
pub fn normalize(mut self) -> Self {
if !INTERVALS.contains(&self.refresh_minutes) {
self.refresh_minutes = 5;
}
self.enabled_providers.sort_by_key(|provider| match provider {
Provider::Claude => 0,
Provider::Codex => 1,
Provider::Cursor => 2,
});
self.enabled_providers.dedup();
self.executable_overrides
.retain(|_, value| !value.trim().is_empty());
self
}
pub fn enabled(&self, provider: Provider) -> bool {
self.enabled_providers.contains(&provider)
}
pub fn override_for(&self, provider: Provider) -> Option<&str> {
self.executable_overrides
.get(&provider)
.map(String::as_str)
.filter(|value| !value.trim().is_empty())
}
pub fn load(path: &Path) -> Self {
let Ok(data) = fs::read(path) else {
return Self::default();
};
serde_json::from_slice::<Settings>(&data)
.map(Self::normalize)
.unwrap_or_else(|_| Self::default())
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(self).unwrap_or_default();
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, data)?;
fs::rename(temporary, path)
}
pub fn load_default() -> Self {
let path = paths::config_path();
if !path.exists() {
let settings = Self::default();
let _ = settings.save(&path);
return settings;
}
Self::load(&path)
}
}
+64
View File
@@ -0,0 +1,64 @@
use std::fs;
use std::path::Path;
use crate::model::{Provider, UsageSnapshot};
#[derive(Debug)]
pub struct StoreError;
impl UsageSnapshot {
pub fn read(path: &Path) -> Result<Self, StoreError> {
if !path.exists() {
return Ok(Self::default());
}
let data = fs::read(path).map_err(|_| StoreError)?;
let snapshot: UsageSnapshot = serde_json::from_slice(&data).map_err(|_| StoreError)?;
if snapshot.schema_version != 1 {
return Err(StoreError);
}
let mut seen = std::collections::HashSet::new();
for state in &snapshot.providers {
if !seen.insert(state.provider) {
return Err(StoreError);
}
}
let mut snapshot = snapshot;
for provider in Provider::ALL {
if !snapshot.providers.iter().any(|state| state.provider == provider) {
snapshot.providers.push(crate::model::ProviderState::pending(provider));
}
}
Ok(snapshot)
}
pub fn write(&self, path: &Path) -> Result<(), StoreError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|_| StoreError)?;
}
let data = serde_json::to_vec_pretty(self).map_err(|_| StoreError)?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, &data).map_err(|_| StoreError)?;
fs::rename(&temporary, path).map_err(|_| StoreError)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn snapshot_round_trip() {
let directory = std::env::temp_dir().join(format!("ai-usage-store-{}", std::process::id()));
let _ = fs::remove_dir_all(&directory);
fs::create_dir_all(&directory).unwrap();
let path = directory.join("usage.json");
let mut snapshot = UsageSnapshot::default();
snapshot.written_at = Utc::now();
snapshot.write(&path).unwrap();
let loaded = UsageSnapshot::read(&path).unwrap();
assert_eq!(loaded.schema_version, 1);
assert_eq!(loaded.providers.len(), 3);
let _ = fs::remove_dir_all(directory);
}
}
+258
View File
@@ -0,0 +1,258 @@
use chrono::{DateTime, Utc};
use crate::formatting::{self, bar_number, level};
use crate::model::{Provider, ProviderState, UsageSnapshot, UsageWindow};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WaybarView {
pub text: String,
pub tooltip: String,
pub class: String,
}
pub fn render(snapshot: &UsageSnapshot, now: DateTime<Utc>, enabled: &[Provider]) -> WaybarView {
let mut parts = Vec::new();
let mut tooltip = Vec::new();
let mut worst = "unknown";
let mut severity = level_rank("unknown");
let mut any = false;
if snapshot.is_demo {
tooltip.push("DEMO".to_string());
}
for provider in Provider::ALL {
if !enabled.contains(&provider) {
continue;
}
let state = snapshot.state(provider);
if state.status == crate::model::FetchStatus::Disabled {
continue;
}
any = true;
let primary = displayed_window(provider, &state);
parts.push(format!(
"{} {}",
provider.bar_label(),
bar_number(primary.as_ref(), snapshot.show_used_percentage)
));
let remaining = primary.as_ref().and_then(|window| window.remaining_percentage);
let mut provider_level = level(remaining);
if state.is_stale(now) && provider_level == "ok" {
provider_level = "warning";
}
if level_rank(provider_level) > severity {
severity = level_rank(provider_level);
worst = provider_level;
}
tooltip.push(provider_block(provider, &state, snapshot.show_used_percentage, now));
}
if !any {
return WaybarView {
text: "AI usage off".to_string(),
tooltip: "Every provider is disabled.".to_string(),
class: "unknown".to_string(),
};
}
if tooltip.iter().all(|line| line.contains("Awaiting first update")) && parts.iter().all(|part| part.ends_with("—")) {
worst = "unknown";
}
WaybarView {
text: parts.join(" "),
tooltip: tooltip.join("\n"),
class: worst.to_string(),
}
}
pub fn json(view: &WaybarView) -> String {
serde_json::json!({
"text": view.text,
"tooltip": view.tooltip,
"class": view.class,
})
.to_string()
}
fn level_rank(class: &str) -> u8 {
match class {
"critical" => 3,
"warning" => 2,
"ok" => 1,
_ => 0,
}
}
fn displayed_window(provider: Provider, state: &ProviderState) -> Option<UsageWindow> {
let usage = state.last_successful_usage.as_ref()?;
match provider {
Provider::Cursor => usage
.display_billing_pools()
.into_iter()
.next()
.map(|pool| pool.window)
.or_else(|| usage.billing_window.clone()),
_ => usage
.short_window
.clone()
.or_else(|| usage.weekly_window.clone()),
}
}
fn provider_block(provider: Provider, state: &ProviderState, show_used: bool, now: DateTime<Utc>) -> String {
let mut lines = vec![provider.display_name().to_string()];
if state.is_stale(now) {
lines.push("Stale".to_string());
}
if let Some(message) = &state.message {
lines.push(message.clone());
}
let Some(usage) = &state.last_successful_usage else {
lines.push(state.status.label().to_string());
return lines.join("\n");
};
if provider == Provider::Cursor {
for pool in usage.display_billing_pools() {
lines.push(format!(
"{} {}",
pool.name,
formatting::percentage(Some(&pool.window), show_used)
));
}
if usage.display_billing_pools().is_empty() {
if let Some(window) = &usage.billing_window {
lines.push(format!("Included {}", formatting::percentage(Some(window), show_used)));
}
}
lines.push(
usage
.billing_reset_label
.as_ref()
.map(|label| format!("Resets {label}"))
.unwrap_or_else(|| "Reset unknown".to_string()),
);
for metric in usage.display_additional_usage() {
lines.push(format!("{}: {}", metric.name, metric.value));
}
} else {
lines.push(window_line("5h", usage.short_window.as_ref(), show_used, now));
lines.push(window_line("Week", usage.weekly_window.as_ref(), show_used, now));
for metric in &usage.additional_usage {
lines.push(format!("{}: {}", metric.name, metric.value));
}
}
lines.push(format!("Updated {}", formatting::clock(Some(usage.fetched_at))));
lines.join("\n")
}
fn window_line(label: &str, window: Option<&UsageWindow>, show_used: bool, now: DateTime<Utc>) -> String {
let percent = formatting::percentage(window, show_used);
let reset = window
.and_then(|window| window.reset_at)
.map(|date| formatting::reset(Some(date), now, true))
.unwrap_or_else(|| "—".to_string());
let relative = formatting::relative_reset(window.and_then(|window| window.reset_at), now);
format!("{label} {percent} {reset} {relative}")
}
pub fn status_text(snapshot: &UsageSnapshot, active: bool, now: DateTime<Utc>, enabled: &[Provider]) -> String {
let view = render(snapshot, now, enabled);
format!(
"{}\n{}\n{}",
if active { "Updates active" } else { "Updates paused" },
view.text,
view.tooltip
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{BillingUsagePool, FetchStatus, ProviderState, ProviderUsage};
#[test]
fn bar_uses_remaining_percentages_and_the_worst_level() {
let now = Utc::now();
let snapshot = UsageSnapshot {
schema_version: 1,
written_at: now,
show_used_percentage: false,
is_demo: false,
providers: vec![
state(Provider::Claude, Some(usage_window(Provider::Claude, 62.0, now))),
state(Provider::Codex, Some(usage_window(Provider::Codex, 29.0, now))),
cursor_state(now),
],
};
let view = render(&snapshot, now, &Provider::ALL);
assert_eq!(view.text, "C 38% X 71% Cu 52%");
assert_eq!(view.class, "warning");
assert!(view.tooltip.contains("Cursor models"));
assert!(view.tooltip.contains("Resets Oct 30"));
}
#[test]
fn bar_hides_providers_that_are_not_enabled() {
let now = Utc::now();
let snapshot = UsageSnapshot {
schema_version: 1,
written_at: now,
show_used_percentage: false,
is_demo: false,
providers: vec![
state(Provider::Claude, Some(usage_window(Provider::Claude, 62.0, now))),
state(Provider::Codex, Some(usage_window(Provider::Codex, 29.0, now))),
cursor_state(now),
],
};
let view = render(&snapshot, now, &[Provider::Cursor]);
assert_eq!(view.text, "Cu 52%");
assert!(!view.tooltip.contains("Claude"));
assert!(view.tooltip.contains("Cursor"));
}
fn state(provider: Provider, usage: Option<ProviderUsage>) -> ProviderState {
ProviderState {
provider,
last_successful_usage: usage,
last_attempt_at: Some(Utc::now()),
status: FetchStatus::Available,
message: None,
}
}
fn usage_window(provider: Provider, used: f64, now: chrono::DateTime<Utc>) -> ProviderUsage {
ProviderUsage {
provider,
fetched_at: now,
short_window: Some(UsageWindow::new(Some(used), None, None)),
weekly_window: None,
billing_window: None,
billing_reset_label: None,
billing_pools: None,
additional_usage: Vec::new(),
}
}
fn cursor_state(now: chrono::DateTime<Utc>) -> ProviderState {
state(
Provider::Cursor,
Some(ProviderUsage {
provider: Provider::Cursor,
fetched_at: now,
short_window: None,
weekly_window: None,
billing_window: Some(UsageWindow::new(Some(44.0), None, None)),
billing_reset_label: Some("Oct 30".to_string()),
billing_pools: Some(vec![
BillingUsagePool {
name: "Cursor models".to_string(),
window: UsageWindow::new(Some(48.0), None, None),
},
BillingUsagePool {
name: "Other models".to_string(),
window: UsageWindow::new(Some(0.0), None, None),
},
]),
additional_usage: vec![],
}),
)
}
}