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 };
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!( "[trace] account.use {name} reading {}", path.display() ) }
let credentials_json = match std::fs::read_to_string( &path )
{
Ok( s ) => { if trace { eprintln!( "[trace] account.use {name} reading: OK" ) } s }
Err( e ) =>
{
if trace
{
eprintln!( "[trace] account.use {name} reading: Err({e})" );
eprintln!( "[trace] account.use {name} subprocess: skipped (reason: fetch failed)" );
}
return PreSwitchOutcome::Unavailable;
}
};
let Some( token ) = crate::account::parse_string_field( &credentials_json, "accessToken" ) else
{
if trace
{
eprintln!( "[trace] account.use {name} quota fetch: Err(no accessToken in credentials)" );
eprintln!( "[trace] account.use {name} subprocess: skipped (reason: fetch failed)" );
}
return PreSwitchOutcome::Unavailable;
};
let quota = match claude_quota::fetch_oauth_usage( &token )
{
Ok( q ) => { if trace { eprintln!( "[trace] account.use {name} quota fetch: OK" ) } q }
Err( e ) =>
{
if trace
{
eprintln!( "[trace] account.use {name} quota fetch: Err({e})" );
eprintln!( "[trace] account.use {name} subprocess: skipped (reason: fetch failed)" );
}
return PreSwitchOutcome::Unavailable;
}
};
if trace { eprintln!( "[trace] account.use {name} subprocess: scheduled (idle check removed)" ) }
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(), "[trace] {label} {name} model override: sonnet→opus (7d(Son) left={sonnet_left:.0}%)" );
}
}
}
else
{
let overrode = crate::account::override_session_model_to_sonnet( paths );
if overrode && trace
{
use std::io::Write as _;
let _ = writeln!( std::io::stderr(), "[trace] {label} {name} model override: opus→sonnet (7d(Son) left={sonnet_left:.0}%)" );
}
}
}
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!( "[trace] account.use {name} model: {model_str} effort: {effort_label}" ) }
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!( "[trace] account.use {name} subprocess: spawned" ) }
let cred_path = paths.base().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( paths.base(), 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 crate::commands::shared::{ is_dry, resolve_account_name, io_err_to_error_data };
let assign_flag = crate::output::parse_int_flag( &cmd, "assign", 0 )? != 0;
let unclaim_flag = crate::output::parse_int_flag( &cmd, "unclaim", 0 )? != 0;
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 unclaim::1 to clear ownership".into() ) ),
_ => None,
};
if owner_value.is_some() && unclaim_flag
{
return Err( ErrorData::new( ErrorCode::ArgumentTypeMismatch,
"owner:: and unclaim::1 are mutually exclusive — set or clear, not both".into() ) );
}
let raw_name_for_owner = match cmd.arguments.get( "name" )
{
Some( Value::String( s ) ) if !s.is_empty() => s.clone(),
_ => String::new(),
};
if owner_value.is_some() && raw_name_for_owner.is_empty()
{
return Err( ErrorData::new( ErrorCode::ArgumentTypeMismatch,
"owner:: requires name:: — batch ownership assignment is not supported".into() ) );
}
if assign_flag
{
let san = | s : &str | -> String
{
s.chars().map( | c | if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' } ).collect()
};
let raw_name = match cmd.arguments.get( "name" )
{
Some( Value::String( s ) ) if !s.is_empty() => s.clone(),
_ => String::new(),
};
if raw_name.is_empty()
{
let user = std::env::var( "USER" )
.or_else( |_| std::env::var( "USERNAME" ) )
.unwrap_or_else( |_| "user".to_string() );
let machine = crate::account::resolve_hostname();
let marker = format!( "_active_{}_{}", san( &machine ), san( &user ) );
let active = std::fs::read_to_string( credential_store.join( &marker ) )
.ok()
.map( | s | s.trim().to_string() )
.filter( | s | !s.is_empty() )
.unwrap_or_else( || "(none)".to_string() );
let ready = if active == "(none)"
{
String::new()
}
else
{
format!(
"\nReady to copy:\n clp .usage assign::1 name::{active}\n clp .usage assign::1 name::{active} for::{user}@{machine}\n"
)
};
let block = format!(
".usage assign::1 \u{2014} write the active-account marker for any machine.\n\n\
\x20 name:: account to assign (required)\n\
\x20 for:: USER@MACHINE to target (default: current machine)\n\
\x20 dry::1 preview without writing\n\n\
Current machine: {user}@{machine} (\u{2192} {marker})\n\
Active account: {active}\n{ready}"
);
return Ok( OutputData::new( block, "text" ) );
}
let name_arg = resolve_account_name( &raw_name, &credential_store )?;
let cred_path = credential_store.join( format!( "{name_arg}.credentials.json" ) );
if !cred_path.exists()
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "account '{name_arg}' not found in credential store" ),
) );
}
let ( marker, for_display ) = match cmd.arguments.get( "for" )
{
Some( Value::String( s ) ) if !s.is_empty() =>
{
let ( usr, mch ) = s.split_once( '@' ).ok_or_else( || ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"for:: must be USER@MACHINE format (no '@' found)".to_string(),
) )?;
if usr.is_empty()
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"for:: user component (left of '@') must not be empty".to_string(),
) );
}
if mch.is_empty()
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
"for:: machine component (right of '@') must not be empty".to_string(),
) );
}
let su = san( usr );
let sm = san( mch );
( format!( "_active_{sm}_{su}" ), format!( "{su}@{sm}" ) )
}
_ =>
{
let user = std::env::var( "USER" )
.or_else( |_| std::env::var( "USERNAME" ) )
.unwrap_or_else( |_| "user".to_string() );
let machine = crate::account::resolve_hostname();
let su = san( &user );
let sm = san( &machine );
( format!( "_active_{sm}_{su}" ), format!( "{su}@{sm}" ) )
}
};
if params.trace { eprintln!( "[trace] usage assign marker: {marker}" ) }
if is_dry( &cmd )
{
return Ok( OutputData::new(
format!( "[dry-run] would assign {name_arg} for {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 assign" ) )?;
return Ok( OutputData::new(
format!( "Assigned {name_arg} for {for_display} \u{2192} {marker}\n" ),
"text",
) );
}
if unclaim_flag
{
let force = params.force;
let raw_name = match cmd.arguments.get( "name" )
{
Some( Value::String( s ) ) if !s.is_empty() => s.clone(),
_ => String::new(),
};
if raw_name.is_empty()
{
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 a in &all_accounts
{
let owner = crate::account::read_owner( &credential_store, &a.name );
if owner.is_empty() { continue; }
if !force && !crate::account::is_owned( &owner )
{
use core::fmt::Write as _;
let _ = writeln!( out, "skip {}: owned by {owner}", a.name );
continue;
}
if is_dry( &cmd )
{
use core::fmt::Write as _;
let _ = writeln!( out, "[dry-run] would unclaim {}", a.name );
continue;
}
crate::account::write_owner( &a.name, &credential_store, "" )
.map_err( |e| io_err_to_error_data( &e, "usage unclaim" ) )?;
use core::fmt::Write as _;
let _ = writeln!( out, "unclaimed {}", a.name );
}
if out.is_empty() { out.push_str( "no owned accounts to unclaim\n" ); }
return Ok( OutputData::new( out, "text" ) );
}
let name_arg = resolve_account_name( &raw_name, &credential_store )?;
let json_path = credential_store.join( format!( "{name_arg}.json" ) );
if !json_path.exists()
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "account not found: {name_arg}" ),
) );
}
let owner = crate::account::read_owner( &credential_store, &name_arg );
if !force && !crate::account::is_owned( &owner )
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
format!( "ownership violation: this account is owned by {owner}" ),
) );
}
if is_dry( &cmd )
{
return Ok( OutputData::new( format!( "[dry-run] would unclaim {name_arg}\n" ), "text" ) );
}
crate::account::write_owner( &name_arg, &credential_store, "" )
.map_err( |e| io_err_to_error_data( &e, "usage unclaim" ) )?;
if params.trace { eprintln!( "[trace] usage unclaim write_owner: OK name={name_arg}" ) }
return Ok( OutputData::new( format!( "unclaimed {name_arg}\n" ), "text" ) );
}
if let Some( ref ov ) = owner_value
{
let name_arg = resolve_account_name( &raw_name_for_owner, &credential_store )?;
let json_path = credential_store.join( format!( "{name_arg}.json" ) );
if !json_path.exists()
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "account not found: {name_arg}" ),
) );
}
let owner = crate::account::read_owner( &credential_store, &name_arg );
if !params.force && !crate::account::is_owned( &owner )
{
return Err( ErrorData::new(
ErrorCode::ArgumentTypeMismatch,
format!( "ownership violation: this account is owned by {owner}" ),
) );
}
if is_dry( &cmd )
{
return Ok( OutputData::new(
format!( "[dry-run] would set owner of {name_arg} to {ov}\n" ), "text",
) );
}
crate::account::write_owner( &name_arg, &credential_store, ov )
.map_err( |e| io_err_to_error_data( &e, "usage owner" ) )?;
if params.trace { eprintln!( "[trace] usage owner write_owner: OK name={name_arg} identity={ov}" ) }
return Ok( OutputData::new( format!( "owned {name_arg} by {ov}\n" ), "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() ); }
if params.exclude_exhausted { accounts.retain( |aq| status_emoji( &aq.result ) == "🟢" ); }
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 ),
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 ),
};
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;