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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ tokscale models --json > report.json # Save to file

The interactive TUI mode provides:

- **6 Views**: Overview (chart + top models), Usage (subscription quotas), Models, Daily, Hourly, Stats (contribution graph), Agents
- **7 Views**: Overview (chart + top models), Usage (subscription quotas), Models, Daily, Hourly, Stats (contribution graph), Agents
- **Keyboard Navigation**:
- `1-6` or `←/→/Tab`: Switch views
- `←/→/Tab`: Switch views
- `↑/↓`: Navigate lists
- `c/d/t`: Sort by cost/date/tokens
- `s`: Open source picker dialog
Expand Down
5 changes: 5 additions & 0 deletions crates/tokscale-cli/src/commands/usage/amp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ fn detect_plan(metrics: &[UsageMetric]) -> Option<String> {
}
}

pub fn has_credentials() -> bool {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(".local").join("share").join("amp").join("secrets.json").exists()
}

pub fn fetch() -> Result<UsageOutput> {
let api_key = read_credentials()?;

Expand Down
6 changes: 6 additions & 0 deletions crates/tokscale-cli/src/commands/usage/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ fn read_keychain() -> Result<String> {
super::helpers::read_keychain("Claude Code-credentials")
}

pub fn has_credentials() -> bool {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(".claude").join(".credentials.json").exists()
|| super::helpers::read_keychain("Claude Code-credentials").is_ok()
}

fn read_credentials() -> Result<Credentials> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let path = home.join(".claude").join(".credentials.json");
Expand Down
51 changes: 42 additions & 9 deletions crates/tokscale-cli/src/commands/usage/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,30 +48,63 @@ struct Refresh {

fn read_credentials() -> Result<Auth> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let paths = [
home.join(".config").join("codex").join("auth.json"),
home.join(".codex").join("auth.json"),
];
let mut paths: Vec<std::path::PathBuf> = Vec::new();

// CODEX_HOME takes precedence
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
paths.push(std::path::PathBuf::from(codex_home).join("auth.json"));
}
paths.push(home.join(".config").join("codex").join("auth.json"));
paths.push(home.join(".codex").join("auth.json"));

for p in &paths {
if p.exists() {
let content = std::fs::read_to_string(p)?;
if let Ok(auth) = serde_json::from_str::<Auth>(&content) {
if auth.tokens.is_some() {
// 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);
}
}
}
}

// macOS keychain fallback
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);
}
}
}

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

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") {
if std::path::PathBuf::from(codex_home).join("auth.json").exists() {
return true;
}
}
if home.join(".config").join("codex").join("auth.json").exists() {
return true;
}
if home.join(".codex").join("auth.json").exists() {
return true;
}
super::helpers::read_keychain("Codex Auth").is_ok()
}

async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result<Refresh> {
let resp = client
.post("https://auth.openai.com/oauth/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(format!(
"grant_type=refresh_token&client_id={CLIENT_ID}&refresh_token={rt}"
))
.form(&[
("grant_type", "refresh_token"),
("client_id", CLIENT_ID),
("refresh_token", rt),
])
.send()
.await?;
if !resp.status().is_success() {
Expand Down
26 changes: 21 additions & 5 deletions crates/tokscale-cli/src/commands/usage/copilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,17 @@ fn read_token_from_keychain() -> Result<String> {
}
}

fn gh_config_dir() -> std::path::PathBuf {
std::env::var("GH_CONFIG_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(".config").join("gh")
})
}

fn read_token_from_hosts() -> Result<String> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let path = home.join(".config").join("gh").join("hosts.yml");
let path = gh_config_dir().join("hosts.yml");
if !path.exists() {
anyhow::bail!("No gh hosts file");
}
Expand All @@ -58,15 +66,16 @@ fn read_token_from_hosts() -> Result<String> {
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 Ok(token.to_string());
}
}
if in_github && !trimmed.is_empty() && !trimmed.starts_with("oauth_token") && !trimmed.starts_with('#') {
in_github = false;
}
}
anyhow::bail!("No oauth_token found in hosts.yml")
}
Expand Down Expand Up @@ -151,6 +160,13 @@ async fn fetch_api(client: &reqwest::Client, token: &str) -> Result<serde_json::
Ok(resp.json().await?)
}

pub fn has_credentials() -> bool {
if super::helpers::read_keychain("gh:github.com").is_ok() {
return true;
}
gh_config_dir().join("hosts.yml").exists()
}

pub fn fetch() -> Result<UsageOutput> {
let token = read_credentials()?;

Expand Down
3 changes: 3 additions & 0 deletions crates/tokscale-cli/src/commands/usage/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ pub fn capitalize(s: &str) -> String {
}

pub fn read_keychain(service: &str) -> Result<String> {
if cfg!(not(target_os = "macos")) {
anyhow::bail!("Keychain lookup is only available on macOS");
}
let out = std::process::Command::new("security")
.args(["find-generic-password", "-s", service, "-w"])
.output()?;
Expand Down
53 changes: 39 additions & 14 deletions crates/tokscale-cli/src/commands/usage/kimi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ struct Credentials {
expires_at: Option<f64>,
}

#[derive(Debug, Deserialize)]
struct RefreshResponse {
access_token: Option<String>,
refresh_token: Option<String>,
expires_in: Option<i64>,
}

#[derive(Debug, Deserialize)]
struct UsageResponse {
usage: Option<QuotaDetail>,
Expand Down Expand Up @@ -52,15 +59,6 @@ struct Membership {
level: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RefreshResponse {
access_token: Option<String>,
#[allow(dead_code)]
refresh_token: Option<String>,
#[allow(dead_code)]
expires_in: Option<i64>,
}

fn read_credentials() -> Result<Credentials> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let path = home.join(".kimi").join("credentials").join("kimi-code.json");
Expand All @@ -71,6 +69,20 @@ fn read_credentials() -> Result<Credentials> {
Ok(serde_json::from_str(&content)?)
}

fn save_credentials(access_token: &str, refresh_token: &str, expires_in: i64) {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let path = home.join(".kimi").join("credentials").join("kimi-code.json");
let expires_at = chrono::Utc::now().timestamp() as f64 + expires_in as f64;
let json = serde_json::json!({
"access_token": access_token,
"refresh_token": refresh_token,
"expires_at": expires_at,
"scope": "kimi-code",
"token_type": "Bearer"
});
let _ = std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap_or_default());
}

fn needs_refresh(expires_at: Option<f64>) -> bool {
if let Some(expires_at) = expires_at {
let now = chrono::Utc::now().timestamp() as f64;
Expand All @@ -83,10 +95,11 @@ fn needs_refresh(expires_at: Option<f64>) -> bool {
async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result<RefreshResponse> {
let resp = client
.post("https://auth.kimi.com/api/oauth/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(format!(
"client_id={CLIENT_ID}&grant_type=refresh_token&refresh_token={rt}"
))
.form(&[
("client_id", CLIENT_ID),
("grant_type", "refresh_token"),
("refresh_token", rt),
])
.send()
.await?;
if !resp.status().is_success() {
Expand Down Expand Up @@ -130,6 +143,11 @@ fn parse_quota_detail(label: &str, detail: &QuotaDetail) -> Option<UsageMetric>
})
}

pub fn has_credentials() -> bool {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(".kimi").join("credentials").join("kimi-code.json").exists()
}

pub fn fetch() -> Result<UsageOutput> {
let creds = read_credentials()?;
let mut access_token = creds
Expand All @@ -147,8 +165,11 @@ pub fn fetch() -> Result<UsageOutput> {
if needs_refresh(expires_at) {
if let Some(ref rt_str) = stored_refresh_token {
if let Ok(refreshed) = refresh_token(&client, rt_str).await {
if let Some(new_token) = refreshed.access_token {
if let Some(new_token) = refreshed.access_token.clone() {
access_token = new_token;
if let (Some(new_rt), Some(expires_in)) = (&refreshed.refresh_token, refreshed.expires_in) {
save_credentials(&access_token, new_rt, expires_in);
}
}
}
}
Expand All @@ -163,7 +184,11 @@ pub fn fetch() -> Result<UsageOutput> {
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 (Some(new_rt), Some(expires_in)) = (&refreshed.refresh_token, refreshed.expires_in) {
save_credentials(&new, new_rt, expires_in);
}
fetch_usage(&client, &new).await?
}
Err(e) => return Err(e),
Expand Down
Loading