mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-28 04:18:21 +00:00
feat: port the data layer to ureq with the Windows system proxy
Replace the WinHTTP client with a ureq agent that keeps the same Client/Response surface: status errors come back as responses, gzip, 60 s connect / 30 s receive / 120 s total timeouts, rustls with the platform verifier and a per-request User-Agent override. Environment proxies come from ureq. On Windows, when none is set, the static Internet Settings proxy is read from HKCU and parsed by a pure core module (per-scheme ProxyServer, ProxyOverride globs and <local>); bypassed hosts go through a direct agent. PAC/WPAD is not evaluated. The usage providers and credential sources compile again on every platform. Refresh keeps the Windows candidates and CREATE_NO_WINDOW; Unix spawns claude/codex from PATH plus the usual per-user install directories and reaps the child on a detached thread. An empty CODEX_HOME now counts as unset and a float expiresAt is truncated instead of ignored. Fixture tests cover the OAuth usage body, rate-limit headers (including a 429 over loopback), the ChatGPT envelope, ISO 8601 edge cases, credential JSON and proxy strings. An ignored live_poll test polls the real providers.
This commit is contained in:
1 parent
10c43d76af
commit
9e2666ff0e
34 files changed
+1686
-512
No files matched your search
+138
-12
@@ -17,10 +17,7 @@ const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
const BETA_HEADER: &str = "oauth-2025-04-20";
|
||||
const API_VERSION: &str = "2023-06-01";
|
||||
|
||||
const PROBE_MODELS: &[&str] = &[
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-haiku-4-5-20251001",
|
||||
];
|
||||
const PROBE_MODELS: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"];
|
||||
|
||||
pub struct ClaudeProvider {
|
||||
locator: Locator,
|
||||
@@ -86,13 +83,16 @@ fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let body: OauthUsage = match resp.json() {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
Ok(parse_usage_body(resp.body()))
|
||||
}
|
||||
|
||||
/// Windows from an `/api/oauth/usage` body, or `None` when the body does not
|
||||
/// match the expected shape (which sends the caller to the Messages fallback).
|
||||
fn parse_usage_body(body: &[u8]) -> Option<UsageWindows> {
|
||||
let body: OauthUsage = serde_json::from_slice(body).ok()?;
|
||||
let primary = body.five_hour.map(bucket_to_window).unwrap_or_default();
|
||||
let secondary = body.seven_day.map(bucket_to_window).unwrap_or_default();
|
||||
Ok(Some(UsageWindows { primary, secondary }))
|
||||
Some(UsageWindows { primary, secondary })
|
||||
}
|
||||
|
||||
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
||||
@@ -120,8 +120,12 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
// Even an error response from Messages can carry rate-limit headers.
|
||||
if resp.header("anthropic-ratelimit-unified-5h-utilization").is_some()
|
||||
|| resp.header("anthropic-ratelimit-unified-7d-utilization").is_some()
|
||||
if resp
|
||||
.header("anthropic-ratelimit-unified-5h-utilization")
|
||||
.is_some()
|
||||
|| resp
|
||||
.header("anthropic-ratelimit-unified-7d-utilization")
|
||||
.is_some()
|
||||
{
|
||||
return Ok(headers::parse_anthropic(&resp));
|
||||
}
|
||||
@@ -240,7 +244,7 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
}
|
||||
|
||||
fn is_leap(year: u64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
|
||||
}
|
||||
|
||||
// --- JSON shape ---
|
||||
@@ -256,3 +260,125 @@ struct Bucket {
|
||||
utilization: f64,
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_usage_body_maps_both_buckets() {
|
||||
let w = parse_usage_body(&fixture("anthropic-usage-full.json")).expect("parses");
|
||||
assert_eq!(w.primary.utilization, 42.5);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
assert!(has_reset_times(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_usage_body_keeps_percentages_and_merges_fallback_resets() {
|
||||
let w = parse_usage_body(&fixture("anthropic-usage-partial.json")).expect("parses");
|
||||
assert_eq!(w.primary.utilization, 7.0);
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.resets_at, at(1_709_251_199));
|
||||
assert!(!has_reset_times(&w));
|
||||
|
||||
let fallback = UsageWindows {
|
||||
primary: Window {
|
||||
utilization: 99.0,
|
||||
resets_at: at(10),
|
||||
},
|
||||
secondary: Window {
|
||||
utilization: 99.0,
|
||||
resets_at: at(20),
|
||||
},
|
||||
};
|
||||
let merged = merge_resets(w, fallback);
|
||||
assert_eq!(merged.primary.utilization, 7.0, "percent is not replaced");
|
||||
assert_eq!(merged.primary.resets_at, at(10));
|
||||
assert_eq!(merged.secondary.resets_at, at(1_709_251_199));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bucket_defaults_to_empty_window() {
|
||||
let w = parse_usage_body(&fixture("anthropic-usage-five-hour-only.json")).expect("parses");
|
||||
assert_eq!(w.primary.utilization, 3.0);
|
||||
assert_eq!(w.secondary.utilization, 0.0);
|
||||
assert_eq!(w.secondary.resets_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_buckets_parse_as_empty_windows() {
|
||||
let w = parse_usage_body(&fixture("anthropic-usage-null-buckets.json")).expect("parses");
|
||||
assert_eq!(w.primary.utilization, 0.0);
|
||||
assert_eq!(w.secondary.utilization, 0.0);
|
||||
assert!(!has_reset_times(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_utilization_rejects_body_so_fallback_runs() {
|
||||
assert!(parse_usage_body(&fixture("anthropic-usage-null-utilization.json")).is_none());
|
||||
assert!(parse_usage_body(b"not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_accepts_utc_offsets_and_fractions() {
|
||||
assert_eq!(parse_iso8601("2025-10-21T10:00:00Z"), at(1_761_040_800));
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-10-21T10:00:00.999999+00:00"),
|
||||
at(1_761_040_800)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-10-27T03:00:00+07:00"),
|
||||
at(1_761_508_800)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-10-21T05:30:00-04:30"),
|
||||
at(1_761_040_800)
|
||||
);
|
||||
assert_eq!(parse_iso8601("2025-10-21T10:00:00"), at(1_761_040_800));
|
||||
assert_eq!(parse_iso8601("2024-02-29T23:59:59Z"), at(1_709_251_199));
|
||||
assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), at(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_rejects_malformed_values() {
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-10-21T10:00:00+0000"),
|
||||
None,
|
||||
"colonless offset"
|
||||
);
|
||||
assert_eq!(parse_iso8601("2025-10-21t10:00:00Z"), None, "lowercase t");
|
||||
assert_eq!(parse_iso8601("1969-12-31T23:59:59Z"), None, "before 1970");
|
||||
assert_eq!(
|
||||
parse_iso8601("1970-01-01T00:30:00+01:00"),
|
||||
None,
|
||||
"negative epoch"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-02-29T00:00:00Z"),
|
||||
None,
|
||||
"not a leap year"
|
||||
);
|
||||
assert_eq!(parse_iso8601("2025-13-01T00:00:00Z"), None);
|
||||
assert_eq!(parse_iso8601("2025-10-21 10:00:00Z"), None);
|
||||
assert_eq!(parse_iso8601("2025-10-21T10:00Z"), None);
|
||||
assert_eq!(parse_iso8601(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_expiry_compares_milliseconds() {
|
||||
assert!(!token_is_expired(None));
|
||||
assert!(token_is_expired(Some(0)));
|
||||
assert!(!token_is_expired(Some(i64::MAX)));
|
||||
}
|
||||
}
|
||||
@@ -125,3 +125,52 @@ impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
|
||||
self.and_then(|inner| inner.map(|b| *b))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
fn envelope(name: &str) -> Envelope {
|
||||
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
|
||||
serde_json::from_str(&text).expect("fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_envelope_maps_both_windows() {
|
||||
let w = envelope_to_windows(envelope("chatgpt-usage-full.json")).expect("rate_limit");
|
||||
assert_eq!(w.primary.utilization, 12.0);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_rate_limit_is_a_bad_response() {
|
||||
assert!(envelope_to_windows(envelope("chatgpt-usage-null-rate-limit.json")).is_none());
|
||||
let missing: Envelope = serde_json::from_str("{}").expect("parses");
|
||||
assert!(envelope_to_windows(missing).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_window_defaults_to_empty() {
|
||||
let w =
|
||||
envelope_to_windows(envelope("chatgpt-usage-null-windows.json")).expect("rate_limit");
|
||||
assert_eq!(w.primary.utilization, 55.0);
|
||||
assert_eq!(w.secondary.utilization, 0.0);
|
||||
assert_eq!(w.secondary.resets_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_reset_has_no_reset_time_and_percent_is_clamped() {
|
||||
let w =
|
||||
envelope_to_windows(envelope("chatgpt-usage-negative-reset.json")).expect("rate_limit");
|
||||
assert_eq!(w.primary.utilization, 0.0);
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.resets_at, at(0));
|
||||
}
|
||||
}
|
||||
@@ -53,3 +53,95 @@ fn unix_to_systemtime(secs: Option<i64>) -> Option<SystemTime> {
|
||||
}
|
||||
Some(UNIX_EPOCH + Duration::from_secs(s as u64))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::test_server::serve_once;
|
||||
use crate::net::{Client, USER_AGENT};
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractions_scale_to_percent_and_resets_parse() {
|
||||
let resp = Response::from_parts(
|
||||
200,
|
||||
&[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "0.425"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "1761040800"),
|
||||
("Anthropic-Ratelimit-Unified-7d-Utilization", "1.2"),
|
||||
("anthropic-ratelimit-unified-7d-reset", "1761508800"),
|
||||
],
|
||||
b"",
|
||||
);
|
||||
let w = parse_anthropic(&resp);
|
||||
assert!((w.primary.utilization - 42.5).abs() < 1e-9);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_garbage_values_fall_back_to_empty() {
|
||||
let resp = Response::from_parts(
|
||||
200,
|
||||
&[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "abc"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "soon"),
|
||||
],
|
||||
b"",
|
||||
);
|
||||
let w = parse_anthropic(&resp);
|
||||
assert_eq!(w.primary.utilization, 0.0);
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.utilization, 0.0);
|
||||
assert_eq!(w.secondary.resets_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_values_clamp_and_drop_reset() {
|
||||
let resp = Response::from_parts(
|
||||
200,
|
||||
&[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "-0.3"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "-1"),
|
||||
("anthropic-ratelimit-unified-7d-utilization", "0.5"),
|
||||
("anthropic-ratelimit-unified-7d-reset", "0"),
|
||||
],
|
||||
b"",
|
||||
);
|
||||
let w = parse_anthropic(&resp);
|
||||
assert_eq!(w.primary.utilization, 0.0);
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.utilization, 50.0);
|
||||
assert_eq!(w.secondary.resets_at, at(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_reply_still_yields_headers_over_http() {
|
||||
let served = serve_once(
|
||||
"429 Too Many Requests",
|
||||
&[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "1.0"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "1761040800"),
|
||||
("anthropic-ratelimit-unified-7d-utilization", "0.25"),
|
||||
("anthropic-ratelimit-unified-7d-reset", "1761508800"),
|
||||
],
|
||||
br#"{"type":"error","error":{"type":"rate_limit_error"}}"#.to_vec(),
|
||||
);
|
||||
let client = Client::without_proxy(USER_AGENT);
|
||||
let resp = client
|
||||
.post(&served.base)
|
||||
.json_body(&serde_json::json!({"max_tokens": 1}))
|
||||
.and_then(crate::net::RequestBuilder::send)
|
||||
.expect("429 is a response, not an error");
|
||||
assert_eq!(resp.status(), 429);
|
||||
let w = parse_anthropic(&resp);
|
||||
assert_eq!(w.primary.utilization, 100.0);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_eq!(w.secondary.utilization, 25.0);
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -1,7 +1,35 @@
|
||||
// Usage subsystem. Only the shared data shapes are compiled for now; the
|
||||
// providers, registry, headers and refresh orchestrator return with the
|
||||
// HTTP client port.
|
||||
// Usage subsystem: the provider trait, the per-provider implementations,
|
||||
// the registry that polls them and the credential refresh orchestrator.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod chatgpt;
|
||||
pub mod headers;
|
||||
pub mod refresh;
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use registry::Registry;
|
||||
pub use types::{ProviderId, UsageWindows, Window};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("authentication required")]
|
||||
AuthRequired,
|
||||
#[error("no credentials configured")]
|
||||
NoCredentials,
|
||||
#[error("token expired after refresh attempt")]
|
||||
TokenExpired,
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] crate::net::Error),
|
||||
#[error("credentials: {0}")]
|
||||
Creds(#[from] crate::creds::Error),
|
||||
#[error("unexpected response shape: {0}")]
|
||||
BadResponse(String),
|
||||
}
|
||||
|
||||
/// Every provider exposes a stable identity and a sync `poll` that performs
|
||||
/// HTTP calls against the supplied client.
|
||||
pub trait UsageProvider: Send {
|
||||
fn id(&self) -> ProviderId;
|
||||
fn poll(&mut self, http: &crate::net::Client) -> Result<UsageWindows, Error>;
|
||||
}
|
||||
+220
-34
@@ -4,14 +4,22 @@
|
||||
// which CLI to spawn. We invoke that CLI and watch the credential file's
|
||||
// signature; if it changes within the timeout we declare success.
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::creds::{CredentialSource, RefreshHint};
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Directories searched after `PATH` on Unix. A desktop session often starts
|
||||
/// GUI apps with a shorter `PATH` than the login shell, which misses the
|
||||
/// usual per-user install locations of the Claude and Codex CLIs.
|
||||
#[cfg(unix)]
|
||||
const EXTRA_HOME_DIRS: &[&str] = &[".local/bin", ".claude/local"];
|
||||
#[cfg(unix)]
|
||||
const EXTRA_SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Refreshed,
|
||||
@@ -55,54 +63,232 @@ impl Orchestrator {
|
||||
}
|
||||
|
||||
fn spawn_cli(hint: &RefreshHint) -> bool {
|
||||
let (candidates, args) = cli_for(hint);
|
||||
spawn_first(candidates, args)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
|
||||
match hint {
|
||||
RefreshHint::LocalClaudeCli => {
|
||||
spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."])
|
||||
}
|
||||
RefreshHint::LocalCodexCli => spawn_local(
|
||||
RefreshHint::LocalClaudeCli => (&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
|
||||
RefreshHint::LocalCodexCli => (
|
||||
&["codex.cmd", "codex.ps1", "codex.exe", "codex"],
|
||||
&["exec", "."],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
|
||||
#[cfg(unix)]
|
||||
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
|
||||
match hint {
|
||||
RefreshHint::LocalClaudeCli => (&["claude"], &["-p", "."]),
|
||||
RefreshHint::LocalCodexCli => (&["codex"], &["exec", "."]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the first candidate that starts. The child is detached: a reaper
|
||||
/// thread waits on it so no zombie is left behind.
|
||||
fn spawn_first(candidates: &[&str], args: &[&str]) -> bool {
|
||||
for name in candidates {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let mut cmd = if lower.ends_with(".ps1") {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
c.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
.arg("-File")
|
||||
.arg(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.arg("/c").arg(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
let Some(mut cmd) = command_for(name, args) else {
|
||||
continue;
|
||||
};
|
||||
cmd.env_remove("CLAUDECODE")
|
||||
.env_remove("CLAUDE_CODE_ENTRYPOINT")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
if cmd.spawn().is_ok() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
if let Ok(child) = cmd.spawn() {
|
||||
reap_detached(child);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn reap_detached(mut child: Child) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = child.wait() {
|
||||
log::warn!("refresh CLI could not be reaped: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let mut cmd = if lower.ends_with(".ps1") {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
c.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
.arg("-File")
|
||||
.arg(name);
|
||||
c
|
||||
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.arg("/c").arg(name);
|
||||
c
|
||||
} else {
|
||||
Command::new(name)
|
||||
};
|
||||
cmd.args(args);
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
|
||||
let path_var = std::env::var_os("PATH");
|
||||
let home = dirs::home_dir();
|
||||
let dirs = search_dirs(path_var.as_deref(), home.as_deref());
|
||||
let program = find_executable(name, &dirs)?;
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args);
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
/// `PATH` entries in order, then the extra install locations, without
|
||||
/// duplicates. Empty and relative entries are skipped so the working
|
||||
/// directory is never searched.
|
||||
#[cfg(unix)]
|
||||
fn search_dirs(
|
||||
path_var: Option<&std::ffi::OsStr>,
|
||||
home: Option<&std::path::Path>,
|
||||
) -> Vec<std::path::PathBuf> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let from_path = path_var.map(|p| std::env::split_paths(p).collect::<Vec<_>>());
|
||||
let extras_home = home
|
||||
.into_iter()
|
||||
.flat_map(|h| EXTRA_HOME_DIRS.iter().map(move |d| h.join(d)));
|
||||
let extras_system = EXTRA_SYSTEM_DIRS.iter().map(PathBuf::from);
|
||||
for dir in from_path
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(extras_home)
|
||||
.chain(extras_system)
|
||||
{
|
||||
if dir.is_absolute() && !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// First `dir/name` that is a regular file with an execute bit set.
|
||||
#[cfg(unix)]
|
||||
fn find_executable(name: &str, dirs: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
dirs.iter().map(|d| d.join(name)).find(|candidate| {
|
||||
std::fs::metadata(candidate)
|
||||
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ccub-refresh-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or_default()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, mode: u32) {
|
||||
std::fs::write(path, "#!/bin/sh\n").expect("write");
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_dirs_puts_path_first_then_extras_without_duplicates() {
|
||||
let path = OsString::from("/usr/bin::relative/bin:/usr/local/bin:/usr/bin");
|
||||
let dirs = search_dirs(Some(&path), Some(Path::new("/home/u")));
|
||||
let expected: Vec<PathBuf> = [
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/home/u/.local/bin",
|
||||
"/home/u/.claude/local",
|
||||
"/opt/homebrew/bin",
|
||||
]
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
assert_eq!(dirs, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_dirs_without_path_or_home_keeps_system_extras() {
|
||||
let dirs = search_dirs(None, None);
|
||||
assert_eq!(
|
||||
dirs,
|
||||
vec![
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_executable_skips_missing_and_non_executable_files() {
|
||||
let first = temp_dir("first");
|
||||
let second = temp_dir("second");
|
||||
write_file(&first.join("claude"), 0o644);
|
||||
std::fs::create_dir_all(first.join("codex")).expect("dir named like the CLI");
|
||||
write_file(&second.join("claude"), 0o755);
|
||||
let dirs = vec![first.clone(), second.clone()];
|
||||
assert_eq!(
|
||||
find_executable("claude", &dirs),
|
||||
Some(second.join("claude"))
|
||||
);
|
||||
assert_eq!(find_executable("codex", &dirs), None);
|
||||
assert_eq!(find_executable("absent", &dirs), None);
|
||||
let _ = std::fs::remove_dir_all(first);
|
||||
let _ = std::fs::remove_dir_all(second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawned_child_is_reaped() {
|
||||
let dir = temp_dir("spawn");
|
||||
let script = dir.join("fake-cli");
|
||||
let marker = dir.join("ran");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("#!/bin/sh\necho \"$@\" > '{}'\n", marker.display()),
|
||||
)
|
||||
.expect("write script");
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
let name = script.to_str().expect("utf8 path");
|
||||
assert!(spawn_first(
|
||||
&["definitely-not-a-cli-name", name],
|
||||
&["-p", "."]
|
||||
));
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !marker.exists() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let written = std::fs::read_to_string(&marker).unwrap_or_default();
|
||||
assert_eq!(written.trim(), "-p .");
|
||||
assert!(!spawn_first(&["definitely-not-a-cli-name"], &[]));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
+47
-1
@@ -4,9 +4,9 @@
|
||||
// every cycle. The result is a flat list of `(id, Result<UsageWindows>)`
|
||||
// pairs the app can apply to its state.
|
||||
|
||||
use crate::core::settings::Settings;
|
||||
use crate::creds::Locator;
|
||||
use crate::net::Client;
|
||||
use crate::settings::Settings;
|
||||
use crate::usage::{
|
||||
anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider,
|
||||
UsageWindows,
|
||||
@@ -74,3 +74,49 @@ impl Registry {
|
||||
sigs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::USER_AGENT;
|
||||
|
||||
#[test]
|
||||
fn live_usage_excludes_opencode_go() {
|
||||
// `poll_enabled` relies on this for its `unreachable!` arm.
|
||||
assert!(!ProviderId::LIVE_USAGE.contains(&ProviderId::OpenCodeGo));
|
||||
for provider in ProviderId::ALL {
|
||||
assert_eq!(
|
||||
ProviderId::LIVE_USAGE.contains(&provider),
|
||||
provider.metadata().live_usage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the real providers with the credentials on this machine. Run by
|
||||
/// hand: `cargo test -- --ignored live_poll --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "talks to the real provider endpoints"]
|
||||
fn live_poll() {
|
||||
let http = Client::new(USER_AGENT).expect("client");
|
||||
let mut settings = Settings::default();
|
||||
for provider in ProviderId::ALL {
|
||||
settings.set_provider_enabled(provider, true);
|
||||
}
|
||||
let mut registry = Registry::with_defaults();
|
||||
let results = registry.poll_enabled(&http, &settings);
|
||||
assert_eq!(results.len(), ProviderId::LIVE_USAGE.len());
|
||||
for (provider, result) in results {
|
||||
match result {
|
||||
Ok(w) => println!(
|
||||
"{}: 5h {:.1}% resets {:?}, weekly {:.1}% resets {:?}",
|
||||
provider.slug(),
|
||||
w.primary.utilization,
|
||||
w.primary.resets_at,
|
||||
w.secondary.utilization,
|
||||
w.secondary.resets_at
|
||||
),
|
||||
Err(err) => println!("{}: {err}", provider.slug()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user