Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ tokscale usage --json
tokscale usage --light
```

In the TUI, press `2` or navigate to the **Usage** tab to see subscription data. Press `u` or `r` to refresh.
In the TUI, navigate to the **Usage** tab to see subscription data. Press `u` or `r` to refresh.

#### Supported Providers

Expand Down
12 changes: 8 additions & 4 deletions crates/tokscale-cli/src/commands/usage/amp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,19 @@ fn parse_display_text(text: &str) -> Vec<UsageMetric> {
let after = &text[slash_pos + 2..];
if let Some(space_pos) = after.find(|c: char| c.is_ascii_whitespace()) {
if let Ok(total) = after[..space_pos].replace(',', "").parse::<f64>() {
if total > 0.0 {
if total > 0.0 && total.is_finite() && remaining.is_finite() {
let used = (total - remaining).max(0.0);
let used_pct = (used / total * 100.0).clamp(0.0, 100.0);
let remaining_pct = 100.0 - used_pct;
let used_pct = if used.is_finite() {
(used / total * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
let remaining_pct = (100.0 - used_pct).clamp(0.0, 100.0);
let mut resets_at = None;

// Estimate reset time from hourly replenish rate
if let Some(rate) = parse_dollar_after(text, "+$") {
if rate > 0.0 && used > 0.0 {
if rate > 0.0 && used > 0.0 && rate.is_finite() {
let secs = (used / rate * 3600.0) as i64;
let resets = chrono::Utc::now() + chrono::Duration::seconds(secs);
resets_at = Some(resets.to_rfc3339());
Expand Down
92 changes: 83 additions & 9 deletions crates/tokscale-cli/src/commands/usage/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ struct Window {
#[derive(Debug, Deserialize)]
struct TokenRefresh {
access_token: Option<String>,
refresh_token: Option<String>,
}

#[derive(Debug, Clone, Copy)]
enum CredentialSource {
File,
Keychain,
}

fn read_keychain() -> Result<String> {
Expand All @@ -53,15 +60,70 @@ pub fn has_credentials() -> bool {
|| super::helpers::read_keychain("Claude Code-credentials").is_ok()
}

fn read_credentials() -> Result<Credentials> {
fn read_credentials() -> Result<(Credentials, CredentialSource)> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let path = home.join(".claude").join(".credentials.json");
let content = if path.exists() {
std::fs::read_to_string(&path)?
if path.exists() {
let content = std::fs::read_to_string(&path)?;
let creds: Credentials = serde_json::from_str(&content)?;
Ok((creds, CredentialSource::File))
} else {
read_keychain()?
let content = read_keychain()?;
let creds: Credentials = serde_json::from_str(&content)?;
Ok((creds, CredentialSource::Keychain))
}
}

fn save_credentials(access_token: &str, refresh_token: &str, subscription_type: Option<&str>, rate_limit_tier: Option<&str>) {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let path = home.join(".claude").join(".credentials.json");
let mut oauth = serde_json::json!({
"accessToken": access_token,
"refreshToken": refresh_token,
});
if let Some(st) = subscription_type {
oauth["subscriptionType"] = serde_json::Value::String(st.to_string());
}
if let Some(rlt) = rate_limit_tier {
oauth["rateLimitTier"] = serde_json::Value::String(rlt.to_string());
}
let json = serde_json::json!({
"claudeAiOauth": oauth
});
let content = match serde_json::to_string_pretty(&json) {
Ok(c) => c,
Err(e) => {
eprintln!("warning: failed to serialize Claude credentials: {e}");
return;
}
};
Ok(serde_json::from_str(&content)?)
if let Err(e) = atomic_write_secret(&path, content.as_bytes()) {
eprintln!("warning: failed to save Claude credentials: {e}");
}
}

fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
if path.parent().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path has no parent directory",
));
}
let temp_path = path.with_extension("tmp");
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
std::io::Write::write_all(&mut f, data)?;
}
std::fs::rename(&temp_path, path)?;
Ok(())
}

async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result<TokenRefresh> {
Expand Down Expand Up @@ -115,20 +177,21 @@ fn window_metric(label: &str, w: &Window) -> UsageMetric {
pub fn fetch() -> Result<UsageOutput> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let creds = read_credentials()?;
let (creds, source) = read_credentials()?;
let oauth = creds.claude_ai_oauth.ok_or_else(|| {
anyhow::anyhow!("No Claude OAuth credentials. Run 'claude' to log in.")
})?;
let access_token = oauth
.access_token
.clone()
.ok_or_else(|| anyhow::anyhow!("No Claude access token."))?;
let plan = oauth.subscription_type.map(|s| {
let plan = oauth.subscription_type.as_ref().map(|s| {
let tier = oauth.rate_limit_tier.as_deref().and_then(|t| {
t.rsplit('_').next()
});
match tier {
Some(mult) => format!("{} {}", capitalize(&s), mult),
None => capitalize(&s),
Some(mult) => format!("{} {}", capitalize(s), mult),
None => capitalize(s),
}
});

Expand All @@ -143,7 +206,18 @@ pub fn fetch() -> Result<UsageOutput> {
let refreshed = refresh_token(&client, rt).await?;
let new = refreshed
.access_token
.clone()
.ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?;
if matches!(source, CredentialSource::File) {
if let Some(new_rt) = refreshed.refresh_token.as_deref() {
save_credentials(
&new,
new_rt,
oauth.subscription_type.as_deref(),
oauth.rate_limit_tier.as_deref(),
);
}
}
fetch_usage(&client, &new).await?
}
Err(e) => return Err(e),
Expand Down
92 changes: 86 additions & 6 deletions crates/tokscale-cli/src/commands/usage/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct Tokens {
access_token: Option<String>,
refresh_token: Option<String>,
account_id: Option<String>,
id_token: Option<String>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -44,9 +45,18 @@ struct Window {
#[derive(Debug, Deserialize)]
struct Refresh {
access_token: Option<String>,
refresh_token: Option<String>,
#[allow(dead_code)]
expires_in: Option<i64>,
}

#[derive(Debug, Clone)]
enum CredentialSource {
File(std::path::PathBuf),
Keychain,
}

fn read_credentials() -> Result<Auth> {
fn read_credentials() -> Result<(Auth, CredentialSource)> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let mut paths: Vec<std::path::PathBuf> = Vec::new();

Expand All @@ -63,7 +73,7 @@ fn read_credentials() -> Result<Auth> {
if let Ok(auth) = serde_json::from_str::<Auth>(&content) {
// Only accept if tokens contains a usable access_token
if auth.tokens.as_ref().and_then(|t| t.access_token.as_ref()).is_some() {
return Ok(auth);
return Ok((auth, CredentialSource::File(p.clone())));
}
}
}
Expand All @@ -73,14 +83,71 @@ fn read_credentials() -> Result<Auth> {
if let Ok(raw) = super::helpers::read_keychain("Codex Auth") {
if let Ok(auth) = serde_json::from_str::<Auth>(&raw) {
if auth.tokens.as_ref().and_then(|t| t.access_token.as_ref()).is_some() {
return Ok(auth);
return Ok((auth, CredentialSource::Keychain));
}
}
}

anyhow::bail!("No Codex credentials found. Run 'codex' to log in.")
}

fn save_credentials(
path: &std::path::Path,
access_token: &str,
refresh_token: &str,
account_id: Option<&str>,
id_token: Option<&str>,
) {
let mut tokens = serde_json::json!({
"access_token": access_token,
"refresh_token": refresh_token,
});
if let Some(aid) = account_id {
tokens["account_id"] = serde_json::Value::String(aid.to_string());
}
if let Some(it) = id_token {
tokens["id_token"] = serde_json::Value::String(it.to_string());
}
let json = serde_json::json!({
"tokens": tokens,
"last_refresh": chrono::Utc::now().to_rfc3339(),
});
let content = match serde_json::to_string_pretty(&json) {
Ok(c) => c,
Err(e) => {
eprintln!("warning: failed to serialize Codex credentials: {e}");
return;
}
};
if let Err(e) = atomic_write_secret(path, content.as_bytes()) {
eprintln!("warning: failed to save Codex credentials: {e}");
}
}

fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
if path.parent().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path has no parent directory",
));
}
let temp_path = path.with_extension("tmp");
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
std::io::Write::write_all(&mut f, data)?;
}
std::fs::rename(&temp_path, path)?;
Ok(())
}

pub fn has_credentials() -> bool {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
Expand Down Expand Up @@ -140,27 +207,40 @@ async fn fetch_usage(client: &reqwest::Client, token: &str, account_id: Option<&
pub fn fetch() -> Result<UsageOutput> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let auth = read_credentials()?;
let (auth, source) = read_credentials()?;
let tokens = auth
.tokens
.ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?;
let access_token = tokens
.access_token
.clone()
.ok_or_else(|| anyhow::anyhow!("No Codex access token."))?;
let account_id = tokens.account_id.as_deref();

let client = reqwest::Client::new();
let resp = match fetch_usage(&client, &access_token, account_id).await {
Ok(r) => r,
Err(e) if e.to_string().contains("NEEDS_AUTH") => {
let rt = tokens
let rt_str = tokens
.refresh_token
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No refresh token."))?;
let refreshed = refresh_token(&client, rt).await?;
let refreshed = refresh_token(&client, rt_str).await?;
let new = refreshed
.access_token
.clone()
.ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?;
if let CredentialSource::File(ref path) = source {
if let Some(new_rt) = refreshed.refresh_token.as_deref() {
save_credentials(
path,
&new,
new_rt,
tokens.account_id.as_deref(),
tokens.id_token.as_deref(),
);
}
}
fetch_usage(&client, &new, account_id).await?
}
Err(e) => return Err(e),
Expand Down
38 changes: 32 additions & 6 deletions crates/tokscale-cli/src/commands/usage/copilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,33 @@ pub fn has_credentials() -> bool {
if super::helpers::read_keychain("gh:github.com").is_ok() {
return true;
}
gh_config_dir().join("hosts.yml").exists()
// Check that hosts.yml exists and contains a github.com: section with an oauth_token
let path = gh_config_dir().join("hosts.yml");
if !path.exists() {
return false;
}
let Ok(content) = std::fs::read_to_string(&path) else {
return false;
};
let mut in_github = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "github.com:" {
in_github = true;
continue;
}
// A non-indented, non-empty, non-comment line starts a new section
if in_github && !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() && !trimmed.starts_with('#') {
in_github = false;
}
if in_github && trimmed.starts_with("oauth_token:") {
let token = trimmed.trim_start_matches("oauth_token:").trim();
if !token.is_empty() {
return true;
}
}
}
false
}

pub fn fetch() -> Result<UsageOutput> {
Expand All @@ -189,14 +215,14 @@ pub fn fetch() -> Result<UsageOutput> {

for (key, value) in snapshots {
let pct_remaining = value.get("percent_remaining")
.and_then(|v| v.as_i64())
.unwrap_or(100)
.clamp(0, 100);
.and_then(|v| v.as_f64())
.unwrap_or(100.0)
.clamp(0.0, 100.0);
let remaining = value.get("remaining").and_then(|v| v.as_i64());
let entitlement = value.get("entitlement").and_then(|v| v.as_i64());

let used_pct = (100 - pct_remaining) as f64;
let remaining_pct = pct_remaining as f64;
let used_pct = 100.0 - pct_remaining;
let remaining_pct = pct_remaining;

let remaining_label = match (remaining, entitlement) {
(Some(r), Some(e)) => Some(format!("{r}/{e} left")),
Expand Down
Loading