diff --git a/CHANGELOG.md b/CHANGELOG.md index de108e18..36b6aec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **A leaked `serve.token` is rotated on Windows, not just Unix.** After SBS-953, a world-readable token was replaced on Unix, but Windows still tightened the DACL and reused the same secret. The ACL is now inspected before tightening; if anyone other than the current user, SYSTEM, or Administrators can read the file, the token is replaced. Closes SBS-1043. ### Fixed +- **`usage --all-accounts` now fetches every configured Codex and Claude account.** Account fetches run with bounded concurrency, preserve configured order, and report failures independently. Text and JSON identify each configured account while the default output remains unchanged. Fixes #274. - **The detached Settings window now reopens where you left it.** Its saved size and position are restored and clamped on screen instead of being overwritten by a second frontend resize on every open. Closes #275. - **`serve --refresh-interval` now controls response caching.** Successful usage and cost responses are cached separately by provider selection for the requested TTL. A zero interval disables caching, and provider failures are retried on the next request. Fixes #273. - **Codex latest-session cost follows transcript time.** Copying or touching an older rollout no longer makes it replace a newer session in local cost summaries. Fixes #271. diff --git a/docs/CLI.md b/docs/CLI.md index 50e98a7b..b2246f6a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -48,6 +48,8 @@ Useful options: - `--source ` - data source. - `--brief` - one compact line per provider. +Without `--all-accounts`, JSON keeps its existing one-entry-per-provider shape. With the flag, directory-backed providers emit one entry per configured account and add a `configured_account` object containing its stable `id` and display `label`. Providers without configured account support still emit one unchanged entry. + Examples: ```sh diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index 0b4093d5..ff1e765e 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -1,16 +1,18 @@ //! Usage command implementation use clap::Args; +use futures::stream::{self, StreamExt}; use serde::Serialize; use crate::core::{ - ConfiguredAccounts, CostSnapshot, FetchContext, ProviderFetchResult, ProviderId, RateWindow, - SourceMode, UsagePace, UsageSnapshot, instantiate_provider, + AccountTarget, ConfiguredAccounts, CostSnapshot, FetchContext, ProviderFetchResult, ProviderId, + RateWindow, SourceMode, UsagePace, UsageSnapshot, instantiate_provider, }; use crate::settings::Settings; use crate::status::{ProviderStatus as StatusInfo, StatusLevel, fetch_provider_status}; pub const PROVIDER_ARG_HELP: &str = "Provider to query (for example: codex, claude, gemini, antigravity/agy, nanogpt, deepseek, codebuff, windsurf, all, both)"; +const MAX_CONCURRENT_ACCOUNT_FETCHES: usize = 4; /// Arguments for the usage command #[derive(Args, Debug, Default)] @@ -179,6 +181,7 @@ struct UsageCommand { pretty: bool, ctx: FetchContext, accounts: ConfiguredAccounts, + all_accounts: bool, } impl UsageCommand { @@ -198,6 +201,7 @@ impl UsageCommand { pretty: args.pretty, ctx: build_usage_fetch_context(&args, source_mode), accounts: ConfiguredAccounts::load(), + all_accounts: args.all_accounts, }) } @@ -243,30 +247,74 @@ enum UsageOutput { }, } +#[derive(Clone)] +struct UsageTarget { + provider: ProviderId, + account: Option, +} + +fn usage_targets(command: &UsageCommand) -> Vec { + command + .providers + .iter() + .flat_map(|provider| { + let accounts = if command.all_accounts { + command.accounts.targets_for(*provider) + } else { + Vec::new() + }; + if accounts.is_empty() { + vec![UsageTarget { + provider: *provider, + account: None, + }] + } else { + accounts + .into_iter() + .map(|account| UsageTarget { + provider: *provider, + account: Some(account), + }) + .collect() + } + }) + .collect() +} + async fn collect_usage_output(command: &UsageCommand) -> UsageOutput { + let targets = usage_targets(command); match command.format { OutputFormat::Text => { - let mut sections = Vec::new(); - for provider_id in &command.providers { - sections.push(fetch_provider_text_output(*provider_id, command).await); - } - UsageOutput::Text(sections) + let mut sections = stream::iter(targets.into_iter().enumerate()) + .map(|(index, target)| async move { + (index, fetch_provider_text_output(&target, command).await) + }) + .buffer_unordered(MAX_CONCURRENT_ACCOUNT_FETCHES) + .collect::>() + .await; + sections.sort_by_key(|(index, _)| *index); + UsageOutput::Text(sections.into_iter().map(|(_, section)| section).collect()) } OutputFormat::Json => { - let mut results = Vec::new(); - for provider_id in &command.providers { - results.push(fetch_provider_json_output(*provider_id, command).await); - } + let mut results = stream::iter(targets.into_iter().enumerate()) + .map(|(index, target)| async move { + (index, fetch_provider_json_output(&target, command).await) + }) + .buffer_unordered(MAX_CONCURRENT_ACCOUNT_FETCHES) + .collect::>() + .await; + results.sort_by_key(|(index, _)| *index); UsageOutput::Json { - results, + results: results.into_iter().map(|(_, result)| result).collect(), pretty: command.pretty, } } } } -async fn fetch_provider_text_output(provider_id: ProviderId, command: &UsageCommand) -> String { - match fetch_provider_result(provider_id, command).await { +async fn fetch_provider_text_output(target: &UsageTarget, command: &UsageCommand) -> String { + let provider_id = target.provider; + let output = match fetch_provider_result(target, command).await { Ok((result, status)) => { if command.brief { render_brief_text(provider_id, &result) @@ -275,34 +323,45 @@ async fn fetch_provider_text_output(provider_id: ProviderId, command: &UsageComm } } Err(e) => render_text_error(provider_id, &e.to_string(), command.use_color), - } + }; + annotate_text_account(output, target.account.as_ref(), command.brief) } async fn fetch_provider_json_output( - provider_id: ProviderId, + target: &UsageTarget, command: &UsageCommand, ) -> serde_json::Value { - match fetch_provider_result(provider_id, command).await { + let provider_id = target.provider; + let mut output = match fetch_provider_result(target, command).await { Ok((result, status)) => render_json_result(provider_id, result, status.as_ref()), Err(e) => serde_json::json!({ "provider": provider_id.cli_name(), "error": e.to_string(), }), - } + }; + annotate_json_account(&mut output, target.account.as_ref()); + output } async fn fetch_provider_result( - provider_id: ProviderId, + target: &UsageTarget, command: &UsageCommand, ) -> anyhow::Result<(ProviderFetchResult, Option)> { + let provider_id = target.provider; let provider = instantiate_provider(provider_id); let status_future = command .fetch_status .then(|| fetch_provider_status(provider_id.cli_name())); - let ctx = command - .ctx - .clone() - .for_account(provider_id, &command.accounts); + let ctx = match &target.account { + Some(account) => command + .ctx + .clone() + .pinned_to_account_dir(provider_id, account.config_dir.clone()), + None => command + .ctx + .clone() + .for_account(provider_id, &command.accounts), + }; let result = provider.fetch_usage(&ctx).await?; let status = if let Some(fut) = status_future { fut.await @@ -312,6 +371,28 @@ async fn fetch_provider_result( Ok((result, status)) } +fn annotate_text_account(output: String, account: Option<&AccountTarget>, brief: bool) -> String { + match account { + Some(account) if brief => { + format!("{output}, account {} ({})", account.label, account.id) + } + Some(account) => format!( + "{output}\n Configured account: {} ({})", + account.label, account.id + ), + None => output, + } +} + +fn annotate_json_account(output: &mut serde_json::Value, account: Option<&AccountTarget>) { + if let Some(account) = account { + output["configured_account"] = serde_json::json!({ + "id": account.id, + "label": account.label, + }); + } +} + fn render_text_error(provider_id: ProviderId, error_msg: &str, use_color: bool) -> String { let header = if use_color { format!("\x1b[1m{}\x1b[0m", provider_id.display_name()) @@ -604,11 +685,101 @@ fn render_progress_bar(percent: f64, width: usize, use_color: bool) -> String { #[cfg(test)] mod tests { use super::*; + use crate::core::{ClaudeIdentity, CodexIdentity, DirectoryAccount}; fn fetch_result(usage: UsageSnapshot) -> ProviderFetchResult { ProviderFetchResult::new(usage, "test") } + fn test_command(all_accounts: bool, accounts: ConfiguredAccounts) -> UsageCommand { + UsageCommand { + format: OutputFormat::Json, + providers: vec![ProviderId::Codex, ProviderId::Claude, ProviderId::Gemini], + use_color: false, + brief: false, + fetch_status: false, + pretty: false, + ctx: FetchContext::default(), + accounts, + all_accounts, + } + } + + #[test] + fn all_accounts_expands_supported_providers_only() { + let mut accounts = ConfiguredAccounts::default(); + accounts + .codex + .add_account(DirectoryAccount::::new( + Some("personal".into()), + "/accounts/personal".into(), + )); + accounts + .codex + .add_account(DirectoryAccount::::new( + Some("work".into()), + "/accounts/work".into(), + )); + accounts + .claude + .add_account(DirectoryAccount::::new( + Some("team".into()), + "/accounts/claude-team".into(), + )); + + let targets = usage_targets(&test_command(true, accounts)); + + assert_eq!(targets.len(), 4); + assert_eq!(targets[0].account.as_ref().unwrap().label, "personal"); + assert_eq!(targets[1].account.as_ref().unwrap().label, "work"); + assert_eq!(targets[2].provider, ProviderId::Claude); + assert_eq!(targets[2].account.as_ref().unwrap().label, "team"); + assert_eq!(targets[3].provider, ProviderId::Gemini); + assert!(targets[3].account.is_none()); + } + + #[test] + fn default_usage_keeps_one_active_target_per_provider() { + let mut accounts = ConfiguredAccounts::default(); + accounts + .codex + .add_account(DirectoryAccount::::new( + Some("personal".into()), + "/accounts/personal".into(), + )); + accounts + .codex + .add_account(DirectoryAccount::::new( + Some("work".into()), + "/accounts/work".into(), + )); + + let targets = usage_targets(&test_command(false, accounts)); + + assert_eq!(targets.len(), 3); + assert!(targets.iter().all(|target| target.account.is_none())); + } + + #[test] + fn configured_account_is_identified_in_json_and_text_errors() { + let account = AccountTarget { + id: "account-id".into(), + label: "work".into(), + tint: None, + config_dir: "/accounts/work".into(), + }; + let mut json = serde_json::json!({"provider": "codex", "error": "failed"}); + + annotate_json_account(&mut json, Some(&account)); + let text = annotate_text_account("Codex Error: failed".into(), Some(&account), false); + let brief = annotate_text_account("Codex: Session 10%".into(), Some(&account), true); + + assert_eq!(json["configured_account"]["id"], "account-id"); + assert_eq!(json["configured_account"]["label"], "work"); + assert!(text.contains("Configured account: work (account-id)")); + assert_eq!(brief, "Codex: Session 10%, account work (account-id)"); + } + #[test] fn text_rendering_shows_sub_one_percent_usage() { let result = fetch_result(UsageSnapshot::new(RateWindow::new(0.4)));