use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use chrono::{DateTime, Utc};
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use crate::cache::Cache;
use crate::usage::AntigravitySnapshot;
const RENEW_TIMEOUT: Duration = Duration::from_secs(25);
const COOLDOWN_SECS: i64 = 600;
const ATTEMPT_FILE: &str = "agy_renew_attempt";
const QUOTA_TIMEOUT: Duration = Duration::from_secs(20);
pub const QUOTA_INTERVAL: Duration = Duration::from_secs(120);
const QUOTA_FAILURE_FILE: &str = "agy_quota_failure";
const NULL_DEVICE: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
#[derive(Debug, Clone, Default)]
pub enum AgyCommand {
#[default]
Off,
Discover,
At(PathBuf),
}
impl AgyCommand {
fn program(&self) -> Option<PathBuf> {
match self {
Self::Off => None,
Self::Discover => discover(),
Self::At(path) => Some(path.clone()),
}
}
pub fn is_available(&self) -> bool {
self.program().is_some()
}
}
fn discover() -> Option<PathBuf> {
let path = std::env::var_os("PATH").unwrap_or_default();
let home = crate::cache::home_dir().ok();
find_agy(&path, home.as_deref())
}
fn find_agy(path_var: &std::ffi::OsStr, home: Option<&Path>) -> Option<PathBuf> {
let names: &[&str] = if cfg!(windows) {
&["agy.exe", "agy.cmd", "agy.bat"]
} else {
&["agy"]
};
let mut dirs: Vec<PathBuf> = std::env::split_paths(path_var).collect();
if let Some(home) = home {
dirs.push(home.join(".local").join("bin"));
}
dirs.iter()
.flat_map(|dir| names.iter().map(move |name| dir.join(name)))
.find(|candidate| candidate.is_file())
}
fn cooling_down(cache: &Cache, file: &str, now: DateTime<Utc>) -> bool {
std::fs::read_to_string(cache.dir().join(file))
.ok()
.and_then(|text| text.trim().parse::<i64>().ok())
.is_some_and(|at| (0..COOLDOWN_SECS).contains(&(now.timestamp() - at)))
}
fn note_attempt(cache: &Cache, file: &str, now: DateTime<Utc>) {
let _ = crate::cache::atomic_write(
&cache.dir().join(file),
now.timestamp().to_string().as_bytes(),
);
}
fn agy_command(program: &Path) -> Command {
let mut cmd = Command::new(program);
cmd.env("AGY_CLI_DISABLE_AUTO_UPDATE", "true")
.stdin(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
cmd.env_remove(var);
}
#[cfg(windows)]
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
cmd
}
pub async fn renew_session(command: &AgyCommand, cache: &Cache, now: DateTime<Utc>) -> bool {
let Some(program) = command.program() else {
return false;
};
if cooling_down(cache, ATTEMPT_FILE, now) {
return false;
}
note_attempt(cache, ATTEMPT_FILE, now);
let mut cmd = agy_command(&program);
cmd.arg("models").stdout(Stdio::null());
let Ok(mut child) = cmd.spawn() else {
return false;
};
matches!(
tokio::time::timeout(RENEW_TIMEOUT, child.wait()).await,
Ok(Ok(status)) if status.success()
)
}
pub async fn quota_snapshot(
command: &AgyCommand,
cache: &Cache,
now: DateTime<Utc>,
) -> Option<AntigravitySnapshot> {
let program = command.program()?;
if cooling_down(cache, QUOTA_FAILURE_FILE, now) {
return None;
}
let snapshot = read_quota(&program)
.await
.as_ref()
.and_then(reshape_quota)
.and_then(|summary| super::fetch::parse_quota_summary(&summary, String::new()).ok());
if snapshot.is_none() {
note_attempt(cache, QUOTA_FAILURE_FILE, now);
}
snapshot
}
async fn read_quota(program: &Path) -> Option<serde_json::Value> {
let mut cmd = agy_command(program);
cmd.args([
"-p",
"/quota",
"--output-format",
"json",
"--log-file",
NULL_DEVICE,
])
.stdout(Stdio::piped());
let mut child = cmd.spawn().ok()?;
let stdout = child.stdout.take()?;
let run = async move {
let mut bytes = Vec::new();
stdout
.take(crate::vendor::MAX_BODY_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.await
.ok()?;
if bytes.len() > crate::vendor::MAX_BODY_BYTES {
return None;
}
let status = child.wait().await.ok()?;
status.success().then_some(bytes)
};
let bytes = tokio::time::timeout(QUOTA_TIMEOUT, run).await.ok()??;
serde_json::from_slice(&bytes).ok()
}
fn reshape_quota(output: &serde_json::Value) -> Option<serde_json::Value> {
if output["status"].as_str() != Some("SUCCESS") {
return None;
}
let groups = output["command"]["data"]["groups"]
.as_array()?
.iter()
.map(|group| {
let buckets = group["buckets"]
.as_array()
.into_iter()
.flatten()
.filter(|bucket| bucket["disabled"].as_bool() != Some(true))
.map(|bucket| {
serde_json::json!({
"bucketId": bucket["id"],
"displayName": bucket["name"],
"window": bucket["window"],
"remainingFraction": bucket["remaining_fraction"],
"resetTime": bucket["reset_time"],
})
})
.collect::<Vec<_>>();
serde_json::json!({ "displayName": group["name"], "buckets": buckets })
})
.collect::<Vec<_>>();
Some(serde_json::json!({ "groups": groups }))
}
#[cfg(all(test, unix))]
pub(crate) mod test_support {
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
const PROBE: &str = "--test-probe";
pub(crate) fn write_script(path: &Path, body: &str) {
std::fs::write(
path,
format!("#!/bin/sh\n[ \"$1\" = {PROBE} ] && exit 0\n{body}\n"),
)
.unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
for _ in 0..500 {
match std::process::Command::new(path).arg(PROBE).status() {
Ok(_) => return,
Err(error) if error.kind() == std::io::ErrorKind::ExecutableFileBusy => {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(error) => panic!("{}: {error}", path.display()),
}
}
panic!("{} stayed busy", path.display());
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use tempfile::TempDir;
fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
test_support::write_script(&path, body);
path
}
fn cache(dir: &TempDir) -> Cache {
let cache = Cache::at(dir.path().join("cache"));
cache.ensure_dir().unwrap();
cache
}
fn now() -> DateTime<Utc> {
DateTime::from_timestamp(1_800_000_000, 0).unwrap()
}
#[tokio::test]
async fn off_never_runs_anything() {
let dir = TempDir::new().unwrap();
assert!(!renew_session(&AgyCommand::Off, &cache(&dir), now()).await);
}
#[tokio::test]
async fn runs_models_with_the_updater_off_and_reports_success() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("args");
let agy = script(
dir.path(),
"agy",
&format!(
"echo \"$@ $AGY_CLI_DISABLE_AUTO_UPDATE\" > '{}'",
marker.display()
),
);
assert!(renew_session(&AgyCommand::At(agy), &cache(&dir), now()).await);
assert_eq!(
std::fs::read_to_string(marker).unwrap().trim(),
"models true"
);
}
#[tokio::test]
async fn a_failing_command_reports_failure() {
let dir = TempDir::new().unwrap();
let agy = script(dir.path(), "agy", "exit 3");
assert!(!renew_session(&AgyCommand::At(agy), &cache(&dir), now()).await);
}
#[tokio::test]
async fn a_missing_program_reports_failure() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("no-such-agy");
assert!(!renew_session(&AgyCommand::At(missing), &cache(&dir), now()).await);
}
#[tokio::test]
async fn an_attempt_is_not_repeated_inside_the_cooldown_even_after_failure() {
let dir = TempDir::new().unwrap();
let cache = cache(&dir);
let count = dir.path().join("count");
let agy = script(
dir.path(),
"agy",
&format!("echo x >> '{}'; exit 1", count.display()),
);
let command = AgyCommand::At(agy);
assert!(!renew_session(&command, &cache, now()).await);
assert!(!renew_session(&command, &cache, now() + chrono::Duration::seconds(30)).await);
assert_eq!(std::fs::read_to_string(&count).unwrap().lines().count(), 1);
let later = now() + chrono::Duration::seconds(COOLDOWN_SECS + 1);
assert!(!renew_session(&command, &cache, later).await);
assert_eq!(std::fs::read_to_string(&count).unwrap().lines().count(), 2);
}
const QUOTA_OUTPUT: &str = r#"{"conversation_id":"","status":"SUCCESS","response":"Gemini Models\tWeekly Limit Remaining\t63%\t2026-10-24T01:11:19Z","duration_seconds":0,"num_turns":0,
"usage":{"input_tokens":0,"output_tokens":0,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":0},
"command":{"name":"usage","data":{"description":"Within each group, models share a weekly limit and a 5-hour limit.","groups":[
{"name":"Gemini Models","description":"Models within this group: Gemini Flash, Gemini Pro","buckets":[
{"id":"gemini-weekly","name":"Weekly Limit Remaining","description":"Weekly","window":"weekly","remaining_fraction":0.6316714286804199,"reset_time":"2026-10-24T01:11:19Z"},
{"id":"gemini-5h","name":"Five Hour Limit Remaining","description":"Five hour","window":"5h","remaining_fraction":0.4934307336807251,"reset_time":"2026-10-17T20:25:45Z"}]},
{"name":"Claude and GPT models","description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS","buckets":[
{"id":"3p-weekly","name":"Weekly Limit Remaining","description":"Weekly","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-24T17:29:37Z"},
{"id":"3p-5h","name":"Five Hour Limit Remaining","description":"Five hour","window":"5h","remaining_fraction":1,"reset_time":"2026-10-17T22:29:37Z"}]},
{"name":"Gemini 4 Argon","description":"For paid Pro plans, Argon quota is separate.","buckets":[
{"id":"gemini-argon-wtu-weekly","name":"Gemini 4 Argon Weekly Limit","description":"Weekly","window":"weekly","remaining_fraction":0,"reset_time":"2026-10-24T01:10:47Z"}]}]}}}"#;
fn printing(dir: &Path, output: &str) -> PathBuf {
script(dir, "agy", &format!("cat <<'EOF'\n{output}\nEOF"))
}
#[tokio::test]
async fn off_never_reads_the_quota() {
let dir = TempDir::new().unwrap();
assert!(
quota_snapshot(&AgyCommand::Off, &cache(&dir), now())
.await
.is_none()
);
}
#[tokio::test]
async fn reads_the_quota_agy_reports_with_its_log_and_updater_off() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("args");
let agy = script(
dir.path(),
"agy",
&format!(
"echo \"$@ $AGY_CLI_DISABLE_AUTO_UPDATE\" > '{}'\ncat <<'EOF'\n{QUOTA_OUTPUT}\nEOF",
marker.display()
),
);
let snap = quota_snapshot(&AgyCommand::At(agy), &cache(&dir), now())
.await
.expect("a clean reading");
assert_eq!(
std::fs::read_to_string(marker).unwrap().trim(),
"-p /quota --output-format json --log-file /dev/null true"
);
assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 51);
assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 37);
assert_eq!(
snap.third_party_session.as_ref().unwrap().utilization_pct,
0
);
assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
let [argon] = snap.model_pools.as_slice() else {
panic!("{:?}", snap.model_pools);
};
assert_eq!(argon.name, "Gemini 4 Argon");
assert_eq!(argon.window.utilization_pct, 100);
}
#[tokio::test]
async fn output_without_a_usable_summary_is_no_reading() {
for output in [
r#"{"status":"ERROR","command":{"data":{"groups":[]}}}"#,
r#"{"status":"SUCCESS","command":{"name":"usage","data":{}}}"#,
r#"{"status":"SUCCESS","command":{"data":{"groups":[{"name":"X","buckets":[{"id":"x-monthly","window":"monthly","remaining_fraction":0.5}]}]}}}"#,
"not json",
] {
let dir = TempDir::new().unwrap();
let agy = printing(dir.path(), output);
assert!(
quota_snapshot(&AgyCommand::At(agy), &cache(&dir), now())
.await
.is_none(),
"{output}"
);
}
}
#[tokio::test]
async fn a_failing_or_oversized_run_is_no_reading() {
for body in [
format!("cat <<'EOF'\n{QUOTA_OUTPUT}\nEOF\nexit 1"),
format!(
"head -c {} /dev/zero | tr '\\0' ' '\ncat <<'EOF'\n{QUOTA_OUTPUT}\nEOF",
crate::vendor::MAX_BODY_BYTES
),
] {
let dir = TempDir::new().unwrap();
let agy = script(dir.path(), "agy", &body);
assert!(
quota_snapshot(&AgyCommand::At(agy), &cache(&dir), now())
.await
.is_none()
);
}
}
#[tokio::test]
async fn a_failed_reading_is_not_repeated_inside_the_cooldown() {
let dir = TempDir::new().unwrap();
let store = cache(&dir);
let count = dir.path().join("count");
let agy = script(
dir.path(),
"agy",
&format!("echo x >> '{}'; exit 1", count.display()),
);
let command = AgyCommand::At(agy);
assert!(quota_snapshot(&command, &store, now()).await.is_none());
let soon = now() + chrono::Duration::seconds(30);
assert!(quota_snapshot(&command, &store, soon).await.is_none());
assert_eq!(std::fs::read_to_string(&count).unwrap().lines().count(), 1);
let later = now() + chrono::Duration::seconds(COOLDOWN_SECS + 1);
assert!(quota_snapshot(&command, &store, later).await.is_none());
assert_eq!(std::fs::read_to_string(&count).unwrap().lines().count(), 2);
}
#[tokio::test]
async fn a_clean_reading_is_not_held_off() {
let dir = TempDir::new().unwrap();
let store = cache(&dir);
let command = AgyCommand::At(printing(dir.path(), QUOTA_OUTPUT));
assert!(quota_snapshot(&command, &store, now()).await.is_some());
let soon = now() + chrono::Duration::seconds(30);
assert!(quota_snapshot(&command, &store, soon).await.is_some());
}
#[test]
fn a_disabled_bucket_is_left_out_of_the_reading() {
let output = serde_json::json!({
"status": "SUCCESS",
"command": {"data": {"groups": [{"name": "Gemini Models", "buckets": [
{"id": "gemini-5h", "window": "5h", "remaining_fraction": 0.5,
"reset_time": "2026-10-17T20:25:45Z"},
{"id": "gemini-weekly", "window": "weekly", "disabled": true}
]}]}}
});
let summary = reshape_quota(&output).unwrap();
assert_eq!(summary["groups"][0]["buckets"].as_array().unwrap().len(), 1);
let snap = super::super::fetch::parse_quota_summary(&summary, String::new()).unwrap();
assert_eq!(snap.session.unwrap().utilization_pct, 50);
assert!(snap.weekly.is_none());
}
#[test]
fn discovery_prefers_path_then_falls_back_to_local_bin() {
let dir = TempDir::new().unwrap();
let on_path = dir.path().join("bin");
let home = dir.path().join("home");
std::fs::create_dir_all(&on_path).unwrap();
std::fs::create_dir_all(home.join(".local/bin")).unwrap();
let fallback = script(&home.join(".local/bin"), "agy", "true");
let path_var = std::env::join_paths([&on_path]).unwrap();
assert_eq!(find_agy(&path_var, Some(&home)), Some(fallback.clone()));
let first = script(&on_path, "agy", "true");
assert_eq!(find_agy(&path_var, Some(&home)), Some(first));
assert_eq!(find_agy("".as_ref(), None), None);
}
}