use unilang::data::{ ErrorCode, ErrorData, OutputData };
use unilang::interpreter::ExecutionContext;
use unilang::semantic::VerifiedCommand;
use claude_quota::OauthUsageData;
use super::types::{ AccountQuota, SubprocessModel, SubprocessEffort, UsageOutputFormat };
use super::subprocess::{ resolve_model, resolve_effort };
use super::fetch::fetch_quota_for_list;
use super::render::{ render_text, render_json, render_tsv, render_plain, extract_get_field };
use super::live::execute_live_mode;
use super::refresh::apply_refresh;
use super::touch::apply_touch;
use super::params::parse_usage_params;
use super::sort::find_next_for_strategy;
use super::format::{ five_hour_left, seven_day_left, status_emoji, OPUS_OVERRIDE_THRESHOLD };
use claude_profile_core::account::trace_ts;
fn apply_no_color( s : String ) -> String
{
s
.replace( "🟢", "ok" )
.replace( "🟡", "warn" )
.replace( "🔴", "err" )
.replace( '→', "->" )
.replace( '✓', "*" )
}
#[ derive( Debug ) ]
pub( crate ) struct TouchCtx
{
quota : OauthUsageData,
}
#[ cfg( test ) ]
impl TouchCtx
{
fn for_test( quota : claude_quota::OauthUsageData ) -> Self
{
Self { quota }
}
}
#[ derive( Debug ) ]
pub( crate ) enum PreSwitchOutcome
{
NeedTouch( TouchCtx ),
Unavailable,
}
pub( crate ) fn validate_imodel_str( s : &str ) -> Result< (), String >
{
SubprocessModel::parse( s ).map( |_| () )
}
pub( crate ) fn validate_effort_str( s : &str ) -> Result< (), String >
{
SubprocessEffort::parse( s ).map( |_| () )
}
pub( crate ) fn attempt_expired_token_refresh(
name : &str,
credential_store : &std::path::Path,
paths : &crate::ClaudePaths,
trace : bool,
imodel_str : &str,
effort_str : &str,
) -> bool
{
let imodel = SubprocessModel::parse( imodel_str ).unwrap_or( SubprocessModel::Auto );
let effort = SubprocessEffort::parse( effort_str ).unwrap_or( SubprocessEffort::Auto );
let aq = AccountQuota
{
name : name.to_string(),
is_current : false,
is_active : false,
is_occupied_elsewhere : false,
expires_at_ms : 0,
result : Err( "401".to_string() ),
account : None,
host : String::new(),
role : String::new(),
renewal_at : None,
cached : false,
cache_age_secs : None,
is_owned : true,
owner : String::new(),
};
let model = super::subprocess::resolve_model( &aq, imodel );
let pre_args = super::subprocess::effort_pre_args( &model, effort );
crate::account::refresh_account_token(
name, credential_store, Some( paths ), trace, "account.use", model, &pre_args,
).is_some()
}
pub( crate ) fn pre_switch_touch_ctx(
name : &str,
store_path : &std::path::Path,
trace : bool,
_imodel_str : &str,
_effort_str : &str,
) -> PreSwitchOutcome
{
let path = store_path.join( format!( "{name}.credentials.json" ) );
if trace { eprintln!( "{}account.use {name} reading {}", trace_ts(), path.display() ) }
let credentials_json = match std::fs::read_to_string( &path )
{
Ok( s ) => { if trace { eprintln!( "{}account.use {name} reading: OK", trace_ts() ) } s }
Err( e ) =>
{
if trace
{
eprintln!( "{}account.use {name} reading: Err({e})", trace_ts() );
eprintln!( "{}account.use {name} subprocess: skipped (reason: fetch failed)", trace_ts() );
}
return PreSwitchOutcome::Unavailable;
}
};
let Some( token ) = crate::account::parse_string_field( &credentials_json, "accessToken" ) else
{
if trace
{
eprintln!( "{}account.use {name} quota fetch: Err(no accessToken in credentials)", trace_ts() );
eprintln!( "{}account.use {name} subprocess: skipped (reason: fetch failed)", trace_ts() );
}
return PreSwitchOutcome::Unavailable;
};
let quota = match claude_quota::fetch_oauth_usage( &token )
{
Ok( q ) => { if trace { eprintln!( "{}account.use {name} quota fetch: OK", trace_ts() ) } q }
Err( e ) =>
{
if trace
{
eprintln!( "{}account.use {name} quota fetch: Err({e})", trace_ts() );
eprintln!( "{}account.use {name} subprocess: skipped (reason: fetch failed)", trace_ts() );
}
return PreSwitchOutcome::Unavailable;
}
};
if trace { eprintln!( "{}account.use {name} subprocess: scheduled (idle check removed)", trace_ts() ) }
PreSwitchOutcome::NeedTouch( TouchCtx { quota } )
}
pub( crate ) fn apply_model_override(
quota : &OauthUsageData,
paths : &crate::ClaudePaths,
trace : bool,
label : &str,
name : &str,
)
{
if let Some( ref sonnet ) = quota.seven_day_sonnet
{
let sonnet_left = 100.0 - sonnet.utilization;
if sonnet_left < OPUS_OVERRIDE_THRESHOLD
{
let overrode = crate::account::override_session_model_to_opus( paths );
if overrode
{
claude_profile_core::account::write_cache_string(
paths.base(), name, "model_override", "opus",
);
if trace
{
use std::io::Write as _;
let _ = writeln!( std::io::stderr(), "{}{label} {name} model override: sonnet→opus (7d(Son) left={sonnet_left:.0}%)", trace_ts() );
}
}
}
else
{
let overrode = crate::account::override_session_model_to_sonnet( paths );
if overrode && trace
{
use std::io::Write as _;
let _ = writeln!( std::io::stderr(), "{}{label} {name} model override: opus→sonnet (7d(Son) left={sonnet_left:.0}%)", trace_ts() );
}
}
}
else
{
let _ = crate::account::override_session_model_to_sonnet( paths );
}
if claude_profile_core::account::get_session_effort( paths ).is_none()
{
claude_profile_core::account::set_session_effort( paths, "low" );
}
}
pub( crate ) fn apply_post_switch_touch(
name : &str,
ctx : TouchCtx,
imodel_str : &str,
effort_str : &str,
trace : bool,
paths : &crate::ClaudePaths,
credential_store : &std::path::Path,
)
{
let imodel = SubprocessModel::parse( imodel_str ).unwrap_or( SubprocessModel::Auto );
let effort = SubprocessEffort::parse( effort_str ).unwrap_or( SubprocessEffort::Auto );
apply_model_override( &ctx.quota, paths, trace, "account.use", name );
let aq = AccountQuota
{
name : name.to_string(),
is_current : false,
is_active : false,
is_occupied_elsewhere : false,
expires_at_ms : 0,
result : Ok( ctx.quota ),
account : None,
host : String::new(),
role : String::new(),
renewal_at : None,
cached : false,
cache_age_secs : None,
is_owned : true,
owner : String::new(),
};
let model = resolve_model( &aq, imodel );
let effort_val = resolve_effort( &model, effort );
let model_str = match &model
{
claude_runner_core::IsolatedModel::Specific( m ) => m.as_str(),
_ => "keep-current",
};
let effort_label = effort_val.unwrap_or( "(none)" );
if trace { eprintln!( "{}account.use {name} model: {model_str} effort: {effort_label}", trace_ts() ) }
let extra_pre_args = match effort_val
{
Some( e ) => vec![ "--effort".to_string(), e.to_string() ],
None => vec![],
};
let _ = crate::account::refresh_account_token(
name, credential_store, Some( paths ), trace, "account.use", model, &extra_pre_args,
);
claude_profile_core::account::write_cache_string(
paths.base(), name, "last_touch_at", &claude_profile_core::account::chrono_now_utc(),
);
claude_profile_core::account::write_cache_bool(
paths.base(), name, "touch_idle", false,
);
if trace { eprintln!( "{}account.use {name} subprocess: spawned", trace_ts() ) }
let cred_path = credential_store.join( format!( "{name}.credentials.json" ) );
if let Ok( fresh_json ) = std::fs::read_to_string( &cred_path )
{
if let Some( token ) = crate::account::parse_string_field( &fresh_json, "accessToken" )
{
if let Ok( new_data ) = claude_quota::fetch_oauth_usage( &token )
{
let h5 = new_data.five_hour.as_ref().map( |p| ( p.utilization, p.resets_at.as_deref() ) );
let d7 = new_data.seven_day.as_ref().map( |p| ( p.utilization, p.resets_at.as_deref() ) );
let sn = new_data.seven_day_sonnet.as_ref().map( |p| ( p.utilization, p.resets_at.as_deref() ) );
claude_profile_core::account::write_quota_cache( credential_store, name, h5, d7, sn );
}
}
}
}
#[ allow( clippy::too_many_lines ) ]
#[ inline ]
pub fn usage_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let params = parse_usage_params( &cmd )?;
if params.live == 1
{
if params.format == UsageOutputFormat::Json
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"live monitor mode is incompatible with format::json".to_string(),
) );
}
if params.interval < 30
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"interval must be >= 30".to_string(),
) );
}
if params.jitter > params.interval
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"jitter must not exceed interval".to_string(),
) );
}
}
let persist_paths = crate::PersistPaths::new()
.map_err( |e| ErrorData::new(
ErrorCode::InternalError,
format!( "cannot resolve storage root: {e}" ),
) )?;
let credential_store = persist_paths.credential_store();
let live_creds_file = crate::ClaudePaths::new()
.map_or_else( || std::path::PathBuf::from( "/dev/null" ), |p| p.credentials_file() );
{
use unilang::types::Value;
use core::fmt::Write as _;
use crate::commands::shared::{ is_dry, resolve_account_name, io_err_to_error_data };
if cmd.arguments.contains_key( "assign" )
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"assign:: REMOVED — use assignee::USER@MACHINE name::X (or assignee::0 name::X for current machine)".to_string(),
) );
}
if cmd.arguments.contains_key( "unclaim" )
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"unclaim:: REMOVED — use owner::0 name::X instead (or owner::0 alone to batch-clear)".to_string(),
) );
}
if cmd.arguments.contains_key( "for" )
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"for:: REMOVED — use assignee::USER@MACHINE name::X (or assignee::0 name::X for current machine)".to_string(),
) );
}
if cmd.arguments.contains_key( "active" )
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"active:: REMOVED — use assignee::USER@MACHINE name::X (or assignee::0 name::X for current machine)".to_string(),
) );
}
if let Some( Value::String( av ) ) = cmd.arguments.get( "assignee" )
{
let av = if av == "0"
{
claude_profile_core::account::current_identity()
}
else
{
av.clone()
};
let san = | s : &str | -> String
{
s.chars().map( | c | if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' } ).collect()
};
let ( usr_raw, mch_raw ) = av.split_once( '@' ).ok_or_else( || ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"assignee:: must be USER@MACHINE format (no '@' found) — or use assignee::0 for current machine".to_string(),
) )?;
if usr_raw.is_empty()
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"assignee:: user component (left of '@') must not be empty".to_string(),
) );
}
if mch_raw.is_empty()
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"assignee:: machine component (right of '@') must not be empty".to_string(),
) );
}
let su = san( usr_raw );
let sm = san( mch_raw );
let marker = format!( "_active_{sm}_{su}" );
let display = format!( "{su}@{sm}" );
let raw_name = match cmd.arguments.get( "name" )
{
Some( Value::String( s ) ) => s.clone(),
_ => String::new(),
};
let name_arg = if raw_name.is_empty()
{
raw_name.clone()
}
else
{
resolve_account_name( &raw_name, &credential_store )?
};
if !name_arg.is_empty()
{
let cred_path = credential_store.join( format!( "{name_arg}.credentials.json" ) );
if !cred_path.exists()
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
format!( "account '{name_arg}' not found in credential store" ),
) );
}
if is_dry( &cmd )
{
return Ok( OutputData::new(
format!( "[dry-run] would assign {name_arg} for {display} \u{2192} {marker}\n" ),
"text",
) );
}
std::fs::write( credential_store.join( &marker ), name_arg.as_bytes() )
.map_err( | e | io_err_to_error_data( &e, "usage assignee" ) )?;
if params.trace { eprintln!( "{}usage assignee write marker: {marker} → {name_arg}", trace_ts() ) }
return Ok( OutputData::new(
format!( "assigned {name_arg} for {display} \u{2192} {marker}\n" ),
"text",
) );
}
if is_dry( &cmd )
{
return Ok( OutputData::new(
format!( "[dry-run] would unassign {display} \u{2192} {marker} cleared\n" ),
"text",
) );
}
let marker_path = credential_store.join( &marker );
if marker_path.exists()
{
std::fs::remove_file( &marker_path )
.map_err( | e | io_err_to_error_data( &e, "usage assignee unassign" ) )?;
}
if params.trace { eprintln!( "{}usage assignee cleared marker: {marker}", trace_ts() ) }
return Ok( OutputData::new(
format!( "unassigned {display} \u{2192} {marker} cleared\n" ),
"text",
) );
}
let owner_value = match cmd.arguments.get( "owner" )
{
Some( Value::String( s ) ) if !s.is_empty() => Some( s.clone() ),
Some( Value::String( _ ) ) =>
return Err( ErrorData::new( ErrorCode::ArgumentTypeMismatch,
"owner:: value must be non-empty — use owner::0 to clear ownership".into() ) ),
_ => None,
};
if let Some( ref ov ) = owner_value
{
let is_sentinel = ov.as_str() == "0";
let force = params.force;
let is_dry_run = is_dry( &cmd );
let raw_name = match cmd.arguments.get( "name" )
{
Some( Value::String( s ) ) => s.clone(),
_ => String::new(),
};
let name_arg = if raw_name.is_empty() || raw_name.contains( ',' )
{
raw_name.clone()
}
else
{
resolve_account_name( &raw_name, &credential_store )?
};
if raw_name.is_empty()
{
if !is_sentinel
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"owner::USER@MACHINE requires name:: to specify the target account".to_string(),
) );
}
let all_accounts = crate::account::list( &credential_store )
.map_err( |e| ErrorData::new(
ErrorCode::InternalError,
format!( "cannot read credential store: {e}" ),
) )?;
let mut out = String::new();
for acct in &all_accounts
{
let json_path = credential_store.join( format!( "{}.json", acct.name ) );
if !json_path.exists() { continue; }
let acct_owner = crate::account::read_owner( &credential_store, &acct.name );
if acct_owner.is_empty()
{
writeln!( out, "skip {}", acct.name ).unwrap();
continue;
}
if !force && !crate::account::is_owned( &acct_owner )
{
if params.trace { eprintln!( "{}usage owner batch-skip (foreign owner): {} owner={acct_owner}", trace_ts(), acct.name ) }
writeln!( out, "skip {}", acct.name ).unwrap();
continue;
}
if is_dry_run
{
writeln!( out, "[dry-run] would clear owner of {}", acct.name ).unwrap();
continue;
}
crate::account::write_owner( &acct.name, &credential_store, "" )
.map_err( |e| io_err_to_error_data( &e, "usage owner batch-clear" ) )?;
if params.trace { eprintln!( "{}usage owner cleared: {} was={acct_owner}", trace_ts(), acct.name ) }
writeln!( out, "unclaimed {}", acct.name ).unwrap();
}
return Ok( OutputData::new( out, "text" ) );
}
let target_names : Vec< String > = if raw_name.contains( ',' )
{
raw_name.split( ',' )
.map( | part | resolve_account_name( part.trim(), &credential_store ) )
.collect::< Result< Vec< _ >, _ > >()?
}
else
{
vec![ name_arg ]
};
let mut out = String::new();
for name in &target_names
{
let json_path = credential_store.join( format!( "{name}.json" ) );
if !json_path.exists()
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "account not found: {name}" ),
) );
}
let acct_owner = crate::account::read_owner( &credential_store, name );
if !force && !crate::account::is_owned( &acct_owner )
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
format!( "ownership violation: {name} is owned by {acct_owner}" ),
) );
}
if is_dry_run
{
if is_sentinel
{
writeln!( out, "[dry-run] would clear owner of {name}" ).unwrap();
}
else
{
writeln!( out, "[dry-run] would set owner of {name} to {ov}" ).unwrap();
}
continue;
}
let new_owner = if is_sentinel { "" } else { ov.as_str() };
crate::account::write_owner( name, &credential_store, new_owner )
.map_err( |e| io_err_to_error_data( &e, "usage owner" ) )?;
if params.trace
{
eprintln!( "{}usage owner write_owner: OK name={name} identity={}", trace_ts(), if is_sentinel { "(cleared)" } else { ov } );
}
if is_sentinel
{
writeln!( out, "unclaimed {name}" ).unwrap();
}
else
{
writeln!( out, "owned {name} by {ov}" ).unwrap();
}
}
return Ok( OutputData::new( out, "text" ) );
}
}
if params.live == 1
{
return execute_live_mode( &credential_store, &live_creds_file, ¶ms );
}
let mut acct_list : Vec< crate::account::Account > = crate::account::list( &credential_store )
.map_err( |e| ErrorData::new(
ErrorCode::InternalError,
format!( "cannot read credential store: {e}" ),
) )?;
if params.only_active { acct_list.retain( |aq| aq.is_active ); }
let mut accounts = fetch_quota_for_list( &acct_list, &credential_store, &live_creds_file, false, params.trace, params.solo );
if params.refresh == 1
{
let claude_paths = crate::ClaudePaths::new();
apply_refresh( &mut accounts, &credential_store, claude_paths.as_ref(), params.trace, params.imodel, params.effort, params.solo );
}
if params.touch == 1
{
let claude_paths = crate::ClaudePaths::new();
for aq in &mut accounts
{
apply_touch( aq, &credential_store, claude_paths.as_ref(), params.trace, params.imodel, params.effort, params.solo );
}
}
{
let claude_paths = crate::ClaudePaths::new();
if let Some( ref claude_paths ) = claude_paths
{
if let Some( ref sm ) = params.set_model
{
let model_id = super::types::validate_set_model( sm ).ok().flatten();
claude_profile_core::account::set_session_model( claude_paths, model_id );
}
else if let Some( current ) = accounts.iter().find( |aq| aq.is_current )
{
if let Ok( ref data ) = current.result
{
apply_model_override( data, claude_paths, params.trace, "usage", ¤t.name );
}
}
}
}
{
use std::time::{ SystemTime, UNIX_EPOCH };
let now_secs = SystemTime::now().duration_since( UNIX_EPOCH ).unwrap_or_default().as_secs();
if params.only_next
{
let best_opt = find_next_for_strategy( &accounts, params.sort, params.prefer, now_secs, params.rotate && !params.force );
accounts = match best_opt
{
Some( i ) => { let w = accounts.swap_remove( i ); vec![ w ] }
None => vec![],
};
}
if params.only_valid
{
accounts.retain( |aq| aq.result.is_ok() && !aq.account.as_ref().is_some_and( |a| a.billing_type == "none" ) );
}
if params.exclude_exhausted { accounts.retain( |aq| status_emoji( aq ) == "🟢" ); }
if params.min_5h > 0
{
let threshold = f64::from( params.min_5h );
accounts.retain( |aq| aq.result.is_err() || five_hour_left( aq ) >= threshold );
}
if params.min_7d > 0
{
let threshold = f64::from( params.min_7d );
accounts.retain( |aq| aq.result.is_err() || seven_day_left( aq ) >= threshold );
}
if params.offset > 0
{
let off = usize::try_from( params.offset ).unwrap_or( usize::MAX );
accounts = accounts.into_iter().skip( off ).collect();
}
if params.count > 0 { accounts.truncate( usize::try_from( params.count ).unwrap_or( usize::MAX ) ); }
}
let _ = params.abs;
if let Some( field ) = params.get
{
use std::time::{ SystemTime, UNIX_EPOCH };
let now_secs = SystemTime::now().duration_since( UNIX_EPOCH ).unwrap_or_default().as_secs();
let value = accounts.first()
.map_or_else( String::new, |aq| extract_get_field( aq, field, now_secs ) );
let content = if value.is_empty() { String::new() } else { format!( "{value}\n" ) };
return Ok( OutputData::new( content, "text" ) );
}
let settings_content = crate::ClaudePaths::new()
.and_then( |p| std::fs::read_to_string( p.settings_file() ).ok() );
let session_model_str = settings_content.as_deref()
.and_then( |s| crate::account::parse_string_field( s, "model" ) );
let session_effort_str = settings_content.as_deref()
.and_then( |s| crate::account::parse_string_field( s, "effortLevel" ) );
let session_model = session_model_str.as_deref();
let session_effort = session_effort_str.as_deref();
let content = match params.format
{
UsageOutputFormat::Json => render_json( &accounts ),
UsageOutputFormat::Tsv => render_tsv( &accounts, params.sort, params.desc, params.prefer, ¶ms.cols ),
UsageOutputFormat::Plain => render_plain( &accounts, params.sort, params.desc, params.prefer, ¶ms.cols, session_model, session_effort, Some( &credential_store ), params.who, params.rotate && !params.force ),
UsageOutputFormat::Value => String::new(),
UsageOutputFormat::Text => render_text( &accounts, params.sort, params.desc, params.prefer, ¶ms.cols, session_model, session_effort, Some( &credential_store ), params.who, params.rotate && !params.force ),
};
let content = if params.no_color && params.format != UsageOutputFormat::Tsv
{
apply_no_color( content )
}
else
{
content
};
if params.rotate
{
use std::time::{ SystemTime, UNIX_EPOCH };
use crate::commands::shared::{ is_dry, io_err_to_error_data };
let now_secs = SystemTime::now().duration_since( UNIX_EPOCH ).unwrap_or_default().as_secs();
let gate_ownership = !params.force;
let winner_opt = find_next_for_strategy( &accounts, params.sort, params.prefer, now_secs, gate_ownership );
let Some( winner_idx ) = winner_opt
else
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"no eligible account to rotate to".to_string(),
) );
};
let winner_name = accounts[ winner_idx ].name.clone();
if is_dry( &cmd )
{
let msg = format!( "{content}\n[dry-run] would switch to '{winner_name}'\n" );
return Ok( OutputData::new( msg, "text" ) );
}
let claude_paths = crate::ClaudePaths::new().ok_or_else( || ErrorData::new(
ErrorCode::InternalError,
"$HOME is not set; cannot switch account".to_string(),
) )?;
crate::account::switch_account( &winner_name, &credential_store, &claude_paths )
.map_err( |e| io_err_to_error_data( &e, "usage rotate" ) )?;
if let Ok( ref winner_data ) = accounts[ winner_idx ].result
{
apply_model_override( winner_data, &claude_paths, params.trace, "usage rotate", &winner_name );
}
if let Some( se ) = session_effort
{
claude_profile_core::account::set_session_effort( &claude_paths, se );
}
apply_touch( &mut accounts[ winner_idx ], &credential_store, Some( &claude_paths ), params.trace, params.imodel, params.effort, params.solo );
let src_cred = credential_store.join( format!( "{winner_name}.credentials.json" ) );
let _ = std::fs::copy( &src_cred, claude_paths.credentials_file() );
let msg = format!( "{content}\nswitched to '{winner_name}'\n" );
return Ok( OutputData::new( msg, "text" ) );
}
Ok( OutputData::new( content, "text" ) )
}
#[ cfg( test ) ]
#[ path = "api_tests.rs" ]
mod tests;