use std::io::Write as _;
use std::path::Path;
use claude_core::ClaudePaths;
#[ derive( Debug, Clone ) ]
pub struct Account
{
pub name : String,
pub subscription_type : String,
pub rate_limit_tier : String,
pub expires_at_ms : u64,
pub is_active : bool,
pub email : String,
pub display_name : String,
pub role : String,
pub billing : String,
pub model : String,
pub tagged_id : String,
pub uuid : String,
pub capabilities : Vec< String >,
pub organization_uuid : String,
pub organization_name : String,
pub organization_role : String,
pub workspace_uuid : String,
pub workspace_name : String,
pub profile_host : String,
pub profile_role : String,
}
#[ inline ]
#[ must_use = "check the returned accounts list" ]
pub fn list( credential_store : &Path ) -> Result< Vec< Account >, std::io::Error >
{
if !credential_store.exists() { return Ok( Vec::new() ); }
let active = read_active_marker( credential_store );
let mut accounts = Vec::new();
for entry in std::fs::read_dir( credential_store )?.flatten()
{
let path = entry.path();
let Some( name ) = credential_stem( &path ) else { continue };
let content = std::fs::read_to_string( &path ).unwrap_or_default();
let subscription_type = parse_string_field( &content, "subscriptionType" )
.unwrap_or_default();
let rate_limit_tier = parse_string_field( &content, "rateLimitTier" )
.unwrap_or_default();
let expires_at_ms = parse_u64_field( &content, "expiresAt" )
.unwrap_or( 0 );
let is_active = active.as_deref() == Some( name.as_str() );
let meta_json = std::fs::read_to_string(
credential_store.join( format!( "{name}.json" ) )
).unwrap_or_default();
let email = parse_string_field( &meta_json, "emailAddress" ).unwrap_or_default();
let display_name = parse_string_field( &meta_json, "displayName" ).unwrap_or_default();
let role = parse_string_field( &meta_json, "organizationRole" ).unwrap_or_default();
let billing = parse_string_field( &meta_json, "billingType" ).unwrap_or_default();
let model = parse_string_field( &meta_json, "model" ).unwrap_or_default();
let tagged_id = parse_string_field( &meta_json, "taggedId" ).unwrap_or_default();
let uuid = parse_string_field( &meta_json, "uuid" ).unwrap_or_default();
let capabilities = parse_string_array_field( &meta_json, "capabilities" );
let organization_uuid = parse_string_field( &meta_json, "organization_uuid" ).unwrap_or_default();
let organization_name = parse_string_field( &meta_json, "organization_name" ).unwrap_or_default();
let organization_role = parse_string_field( &meta_json, "organization_role" ).unwrap_or_default();
let workspace_uuid = parse_string_field( &meta_json, "workspace_uuid" ).unwrap_or_default();
let workspace_name = parse_string_field( &meta_json, "workspace_name" ).unwrap_or_default();
let profile_host = parse_string_field( &meta_json, "host" ).unwrap_or_default();
let profile_role = parse_string_field( &meta_json, "role" ).unwrap_or_default();
accounts.push( Account
{
name,
subscription_type,
rate_limit_tier,
expires_at_ms,
is_active,
email,
display_name,
role,
billing,
model,
tagged_id,
uuid,
capabilities,
organization_uuid,
organization_name,
organization_role,
workspace_uuid,
workspace_name,
profile_host,
profile_role,
} );
}
accounts.sort_by( | a, b | a.name.cmp( &b.name ) );
Ok( accounts )
}
#[ inline ]
#[ allow( clippy::too_many_arguments ) ] pub fn save(
name : &str,
credential_store : &Path,
paths : &ClaudePaths,
update_marker : bool,
creds : Option< &[u8] >,
host : Option< &str >,
role : Option< &str >,
owner : Option< &str >,
) -> Result< (), std::io::Error >
{
validate_name( name )?;
std::fs::create_dir_all( credential_store )?;
let dest = credential_store.join( format!( "{name}.credentials.json" ) );
match creds
{
Some( bytes ) => std::fs::write( &dest, bytes )?,
None => { std::fs::copy( paths.credentials_file(), &dest )?; }
}
let meta_path = credential_store.join( format!( "{name}.json" ) );
let mut snapshot = std::fs::read_to_string( &meta_path )
.ok()
.and_then( |s| serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
if let Some( obj ) = snapshot.as_object_mut()
{
if let Ok( live_text ) = std::fs::read_to_string( paths.claude_json_file() )
{
if let Ok( live_val ) = serde_json::from_str::< serde_json::Value >( &live_text )
{
if let Some( oauth ) = live_val.get( "oauthAccount" )
{
obj.insert( "oauthAccount".to_string(), oauth.clone() );
}
}
}
if let Ok( live_settings ) = std::fs::read_to_string( paths.settings_file() )
{
if let Some( model ) = parse_string_field( &live_settings, "model" )
{
obj.insert( "model".to_string(), serde_json::Value::String( model ) );
}
}
#[ cfg( feature = "enabled" ) ]
{
let creds_text = std::fs::read_to_string( paths.credentials_file() ).unwrap_or_default();
if let Some( token ) = parse_string_field( &creds_text, "accessToken" )
{
if let Ok( roles ) = claude_quota::fetch_claude_cli_roles( &token )
{
let val_or_null = | s : &str | -> serde_json::Value
{
if s.is_empty() { serde_json::Value::Null }
else { serde_json::Value::String( s.to_string() ) }
};
obj.insert( "organization_uuid".to_string(), serde_json::Value::String( roles.organization_uuid.clone() ) );
obj.insert( "organization_name".to_string(), serde_json::Value::String( roles.organization_name.clone() ) );
obj.insert( "organization_role".to_string(), serde_json::Value::String( roles.organization_role.clone() ) );
obj.insert( "workspace_uuid".to_string(), val_or_null( &roles.workspace_uuid ) );
obj.insert( "workspace_name".to_string(), val_or_null( &roles.workspace_name ) );
}
}
}
if let Some( h ) = host
{
obj.insert( "host".to_string(), serde_json::Value::String( h.to_string() ) );
}
if let Some( r ) = role
{
obj.insert( "role".to_string(), serde_json::Value::String( r.to_string() ) );
}
if let Some( o ) = owner
{
obj.insert( "owner".to_string(), serde_json::Value::String( o.to_string() ) );
}
}
if snapshot.as_object().is_some_and( |obj| !obj.is_empty() )
{
let _ = std::fs::write( &meta_path, serde_json::to_string_pretty( &snapshot ).map( | s | s + "\n" ).unwrap_or_default() );
}
let _ = std::fs::remove_file( credential_store.join( format!( "{name}.claude.json" ) ) );
let _ = std::fs::remove_file( credential_store.join( format!( "{name}.settings.json" ) ) );
let _ = std::fs::remove_file( credential_store.join( format!( "{name}.roles.json" ) ) );
let _ = std::fs::remove_file( credential_store.join( format!( "{name}.profile.json" ) ) );
if update_marker
{
std::fs::write( credential_store.join( active_marker_filename() ), name )?;
}
Ok( () )
}
#[ inline ]
pub fn check_switch_preconditions( name : &str, credential_store : &Path ) -> Result< (), std::io::Error >
{
validate_name( name )?;
let src = credential_store.join( format!( "{name}.credentials.json" ) );
if !src.exists()
{
return Err( std::io::Error::new(
std::io::ErrorKind::NotFound,
format!( "account '{name}' not found in {}", credential_store.display() ),
) );
}
Ok( () )
}
#[ inline ]
pub fn switch_account( name : &str, credential_store : &Path, paths : &ClaudePaths ) -> Result< (), std::io::Error >
{
check_switch_preconditions( name, credential_store )?;
let src = credential_store.join( format!( "{name}.credentials.json" ) );
let creds = paths.credentials_file();
let tmp = creds.with_extension( "json.tmp" );
std::fs::copy( &src, &tmp )?;
std::fs::rename( &tmp, &creds )?;
let marker = credential_store.join( active_marker_filename() );
std::fs::write( marker, name )?;
{
let live_path = paths.claude_json_file();
{
let mut live_val = std::fs::read_to_string( &live_path )
.ok()
.and_then( |s| serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
if let Some( obj ) = live_val.as_object_mut()
{
let oauth = obj.entry( "oauthAccount" )
.or_insert_with( || serde_json::json!( {} ) );
if let Some( oa_obj ) = oauth.as_object_mut()
{
oa_obj.insert( "emailAddress".to_string(), serde_json::Value::String( name.to_string() ) );
}
}
let _ = std::fs::write( &live_path, serde_json::to_string_pretty( &live_val ).map( | s | s + "\n" ).unwrap_or_default() );
}
let meta_path = credential_store.join( format!( "{name}.json" ) );
let meta_text = std::fs::read_to_string( &meta_path ).unwrap_or_default();
if let Ok( saved_val ) = serde_json::from_str::< serde_json::Value >( &meta_text )
{
if let Some( mut oauth ) = saved_val.get( "oauthAccount" ).cloned()
{
if let Some( oa_obj ) = oauth.as_object_mut()
{
oa_obj.insert( "emailAddress".to_string(), serde_json::Value::String( name.to_string() ) );
if let Some( org_name ) = parse_string_field( &meta_text, "organization_name" )
{
if !org_name.is_empty()
{
oa_obj.insert( "organizationName".to_string(), serde_json::Value::String( org_name ) );
}
}
if let Some( org_uuid ) = parse_string_field( &meta_text, "organization_uuid" )
{
if !org_uuid.is_empty()
{
oa_obj.insert( "organizationUuid".to_string(), serde_json::Value::String( org_uuid ) );
}
}
}
let live_path = paths.claude_json_file();
let mut live_val = std::fs::read_to_string( &live_path )
.ok()
.and_then( |s| serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
if let Some( obj ) = live_val.as_object_mut()
{
obj.insert( "oauthAccount".to_string(), oauth );
}
let _ = std::fs::write( live_path, serde_json::to_string_pretty( &live_val ).map( | s | s + "\n" ).unwrap_or_default() );
}
}
let model = parse_string_field( &meta_text, "model" );
let live_settings_path = paths.settings_file();
let mut live_settings = std::fs::read_to_string( &live_settings_path )
.ok()
.and_then( |s| serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
if let Some( obj ) = live_settings.as_object_mut()
{
match model
{
Some( m ) => { obj.insert( "model".to_string(), serde_json::Value::String( m ) ); }
None => { obj.remove( "model" ); }
}
}
let _ = std::fs::write( live_settings_path, serde_json::to_string_pretty( &live_settings ).map( | s | s + "\n" ).unwrap_or_default() );
}
Ok( () )
}
#[ must_use ]
#[ inline ]
pub fn override_session_model_to_opus( paths : &ClaudePaths ) -> bool
{
let path = paths.settings_file();
let mut live = std::fs::read_to_string( &path )
.ok()
.and_then( | s | serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
let Some( obj ) = live.as_object_mut() else { return false; };
let current = obj.get( "model" ).and_then( | v | v.as_str() ).unwrap_or( "" );
if current.contains( "sonnet" ) || current == "claude-opus-4-6" || current.is_empty()
{
obj.insert( "model".to_string(), serde_json::Value::String( "opus".to_string() ) );
let _ = std::fs::write( path, serde_json::to_string_pretty( &live ).map( | s | s + "\n" ).unwrap_or_default() );
true
}
else
{
false
}
}
#[ must_use ]
#[ inline ]
pub fn override_session_model_to_sonnet( paths : &ClaudePaths ) -> bool
{
let path = paths.settings_file();
let mut live = std::fs::read_to_string( &path )
.ok()
.and_then( | s | serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
let Some( obj ) = live.as_object_mut() else { return false; };
let current = obj.get( "model" ).and_then( | v | v.as_str() ).unwrap_or( "" );
if current.contains( "opus" ) || current == "claude-sonnet-4-6" || current.is_empty()
{
obj.insert( "model".to_string(), serde_json::Value::String( "sonnet".to_string() ) );
let _ = std::fs::write( path, serde_json::to_string_pretty( &live ).map( | s | s + "\n" ).unwrap_or_default() );
true
}
else
{
false
}
}
#[ inline ]
pub fn set_session_model( paths : &ClaudePaths, model_id : Option< &str > )
{
let path = paths.settings_file();
if let Some( parent ) = path.parent() { let _ = std::fs::create_dir_all( parent ); }
let mut live = std::fs::read_to_string( &path )
.ok()
.and_then( | s | serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
let Some( obj ) = live.as_object_mut() else { return; };
match model_id
{
Some( id ) => { obj.insert( "model".to_string(), serde_json::Value::String( id.to_string() ) ); }
None => { obj.remove( "model" ); }
}
let _ = std::fs::write( path, serde_json::to_string_pretty( &live ).map( | s | s + "\n" ).unwrap_or_default() );
}
#[ must_use ]
#[ inline ]
pub fn get_session_model( paths : &ClaudePaths ) -> Option< String >
{
let content = std::fs::read_to_string( paths.settings_file() ).ok()?;
parse_string_field( &content, "model" )
}
#[ inline ]
pub fn set_session_effort( paths : &ClaudePaths, effort_id : &str )
{
let path = paths.settings_file();
if let Some( parent ) = path.parent() { let _ = std::fs::create_dir_all( parent ); }
let mut live = std::fs::read_to_string( &path )
.ok()
.and_then( |s| serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
let Some( obj ) = live.as_object_mut() else { return; };
obj.insert( "effortLevel".to_string(), serde_json::Value::String( effort_id.to_string() ) );
let _ = std::fs::write( path, serde_json::to_string_pretty( &live ).map( |s| s + "\n" ).unwrap_or_default() );
}
#[ must_use ]
#[ inline ]
pub fn get_session_effort( paths : &ClaudePaths ) -> Option< String >
{
let content = std::fs::read_to_string( paths.settings_file() ).ok()?;
parse_string_field( &content, "effortLevel" )
}
#[ inline ]
pub fn check_delete_preconditions( name : &str, credential_store : &Path ) -> Result< (), std::io::Error >
{
validate_name( name )?;
let target = credential_store.join( format!( "{name}.credentials.json" ) );
if !target.exists()
{
return Err( std::io::Error::new(
std::io::ErrorKind::NotFound,
format!( "account '{name}' not found in {}", credential_store.display() ),
) );
}
Ok( () )
}
#[ inline ]
pub fn delete( name : &str, credential_store : &Path ) -> Result< (), std::io::Error >
{
check_delete_preconditions( name, credential_store )?;
std::fs::remove_file( credential_store.join( format!( "{name}.credentials.json" ) ) )?;
let _ = std::fs::remove_file( credential_store.join( format!( "{name}.json" ) ) );
let _ = std::fs::remove_file( credential_store.join( format!( "{name}.claude.json" ) ) );
let _ = std::fs::remove_file( credential_store.join( format!( "{name}.settings.json" ) ) );
let _ = std::fs::remove_file( credential_store.join( format!( "{name}.roles.json" ) ) );
let _ = std::fs::remove_file( credential_store.join( format!( "{name}.profile.json" ) ) );
if read_active_marker( credential_store ).as_deref() == Some( name )
{
let _ = std::fs::remove_file( credential_store.join( active_marker_filename() ) );
}
Ok( () )
}
#[ cfg( feature = "enabled" ) ]
#[ inline ]
#[ must_use = "None means the refresh failed — caller must handle the missing credentials case" ]
pub fn refresh_account_token(
name : &str,
credential_store : &Path,
paths : Option< &ClaudePaths >,
trace : bool,
label : &str,
model : claude_runner_core::IsolatedModel,
extra_pre_args : &[ String ],
) -> Option< String >
{
let mut args : Vec< String > = extra_pre_args.to_vec();
args.push( "--print".to_string() );
args.push( ".".to_string() );
if let Some( p ) = paths
{
refresh_token_with_live_path( name, credential_store, p, trace, label, model, args )
}
else
{
let path = credential_store.join( format!( "{name}.credentials.json" ) );
let creds_json = match std::fs::read_to_string( &path )
{
Ok( s ) => { if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} read credentials: OK" ); } s }
Err( e ) =>
{
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} read credentials: Err({e})" ); }
return None;
}
};
let creds_json = manipulate_expires_at( &creds_json );
let t_run = std::time::Instant::now();
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} run_isolated: invoking claude args={args:?} timeout=35s" ); }
let isolated = match claude_runner_core::run_isolated( &creds_json, args, 35, model )
{
Ok( r ) => r,
Err( e ) =>
{
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} run_isolated: Err({e}) ({:.1}s)", t_run.elapsed().as_secs_f64() ); }
return None;
}
};
if trace
{
let creds_status = if isolated.credentials.is_some() { "Some" } else { "None" };
let _ = writeln!( std::io::stderr(), "[trace] {label} {name} run_isolated: OK credentials={creds_status} ({:.1}s)", t_run.elapsed().as_secs_f64() );
}
let new_creds = isolated.credentials?;
if let Err( e ) = std::fs::write( &path, &new_creds )
{
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} write credentials: Err({e})" ); }
return None;
}
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} write credentials: OK" ); }
Some( new_creds )
}
}
#[ cfg( feature = "enabled" ) ]
fn refresh_token_with_live_path(
name : &str,
credential_store : &Path,
p : &ClaudePaths,
trace : bool,
label : &str,
model : claude_runner_core::IsolatedModel,
args : Vec< String >,
) -> Option< String >
{
let creds_json = match std::fs::read_to_string( credential_store.join( format!( "{name}.credentials.json" ) ) )
{
Ok( s ) => { if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} read credentials: OK" ); } s }
Err( e ) =>
{
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} read credentials: Err({e})" ); }
return None;
}
};
let is_active = {
let marker = credential_store.join( active_marker_filename() );
std::fs::read_to_string( &marker ).ok().is_some_and( |s| s.trim() == name )
};
if is_active
{
if let Ok( live_json ) = std::fs::read_to_string( p.credentials_file() )
{
if live_json.trim() != creds_json.trim()
{
let store_path = credential_store.join( format!( "{name}.credentials.json" ) );
if std::fs::write( &store_path, &live_json ).is_ok()
{
let _ = save( name, credential_store, p, false, Some( live_json.as_bytes() ), None, None, None );
return Some( live_json );
}
}
}
}
let creds_json = manipulate_expires_at( &creds_json );
let t_run = std::time::Instant::now();
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} run_isolated: invoking claude args={args:?} timeout=35s" ); }
let isolated = match claude_runner_core::run_isolated( &creds_json, args, 35, model )
{
Ok( r ) => r,
Err( e ) =>
{
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} run_isolated: Err({e}) ({:.1}s)", t_run.elapsed().as_secs_f64() ); }
return None;
}
};
if trace
{
let creds_status = if isolated.credentials.is_some() { "Some" } else { "None" };
let _ = writeln!( std::io::stderr(), "[trace] {label} {name} run_isolated: OK credentials={creds_status} ({:.1}s)", t_run.elapsed().as_secs_f64() );
}
let Some( new_creds ) = isolated.credentials else
{
if is_active
{
let orig_stored = std::fs::read_to_string(
credential_store.join( format!( "{name}.credentials.json" ) ),
).unwrap_or_default();
if let Ok( live_json ) = std::fs::read_to_string( p.credentials_file() )
{
if live_json.trim() != orig_stored.trim()
{
let store_path = credential_store.join( format!( "{name}.credentials.json" ) );
if std::fs::write( &store_path, &live_json ).is_ok()
{
let _ = save( name, credential_store, p, false, Some( live_json.as_bytes() ), None, None, None );
return Some( live_json );
}
}
}
}
return None;
};
let store_cred_path = credential_store.join( format!( "{name}.credentials.json" ) );
if let Err( e ) = std::fs::write( &store_cred_path, &new_creds )
{
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} write credentials: Err({e})" ); }
return None;
}
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} write credentials: OK" ); }
match save( name, credential_store, p, false, Some( new_creds.as_bytes() ), None, None, None )
{
Ok( () ) => { if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} save: OK" ); } }
Err( e ) =>
{
if trace { let _ = writeln!( std::io::stderr(), "[trace] {label} {name} save: Err({e})" ); }
return None;
}
}
Some( new_creds )
}
#[ must_use ]
#[ inline ]
pub fn manipulate_expires_at( creds_json : &str ) -> String
{
if let Some( start ) = creds_json.find( "\"expiresAt\":" )
{
let after_key = &creds_json[ start + "\"expiresAt\":".len().. ];
if let Some( inner ) = after_key.strip_prefix( '"' )
{
if let Some( end ) = inner.find( '"' )
{
let old_val = &after_key[ ..end + 2 ]; return creds_json.replacen(
&format!( "\"expiresAt\":{old_val}" ),
"\"expiresAt\":\"1\"",
1,
);
}
}
else
{
let end = after_key.find( | c : char | !c.is_ascii_digit() ).unwrap_or( after_key.len() );
let old_val = &after_key[ ..end ];
if !old_val.is_empty()
{
return creds_json.replacen(
&format!( "\"expiresAt\":{old_val}" ),
"\"expiresAt\":1",
1,
);
}
}
}
creds_json.to_string()
}
#[ inline ]
#[ must_use ]
pub fn resolve_hostname() -> String
{
std::env::var( "HOSTNAME" )
.unwrap_or_else( |_|
{
std::fs::read_to_string( "/etc/hostname" )
.unwrap_or_else( |_| "local".to_string() )
.trim()
.to_string()
} )
}
#[ inline ]
#[ must_use ]
pub fn current_identity() -> String
{
let user = std::env::var( "USER" )
.or_else( |_| std::env::var( "USERNAME" ) )
.unwrap_or_else( |_| "user".to_string() );
let hostname = resolve_hostname();
format!( "{user}@{hostname}" )
}
#[ inline ]
#[ must_use ]
pub fn read_owner( credential_store : &Path, name : &str ) -> String
{
let path = credential_store.join( format!( "{name}.json" ) );
std::fs::read_to_string( &path ).ok()
.and_then( |s| parse_string_field( &s, "owner" ) )
.unwrap_or_default()
}
#[ inline ]
#[ must_use ]
pub fn is_owned( owner : &str ) -> bool
{
owner.is_empty() || owner == current_identity()
}
#[ inline ]
pub fn write_owner(
name : &str,
credential_store : &Path,
owner : &str,
) -> Result< (), std::io::Error >
{
let path = credential_store.join( format!( "{name}.json" ) );
let mut map = std::fs::read_to_string( &path )
.ok()
.and_then( |s| serde_json::from_str::< serde_json::Value >( &s ).ok() )
.and_then( |v| v.as_object().cloned() )
.unwrap_or_default();
map.insert( "owner".to_string(), serde_json::Value::String( owner.to_string() ) );
let json = serde_json::to_string_pretty( &serde_json::Value::Object( map ) )
.map( | s | s + "\n" )
.map_err( |e| std::io::Error::new( std::io::ErrorKind::InvalidData, e ) )?;
std::fs::write( &path, json )
}
#[ inline ]
#[ must_use ]
pub fn active_marker_filename() -> String
{
let hostname = resolve_hostname();
let user = std::env::var( "USER" )
.or_else( |_| std::env::var( "USERNAME" ) )
.unwrap_or_else( |_| "user".to_string() );
let clean = | s : &str | -> String
{
s.chars()
.map( | c | if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' } )
.collect()
};
format!( "_active_{}_{}", clean( &hostname ), clean( &user ) )
}
#[ inline ]
#[ must_use ]
pub fn other_machines_active( credential_store : &Path ) -> std::collections::HashSet< String >
{
let own = active_marker_filename();
std::fs::read_dir( credential_store )
.ok()
.into_iter()
.flatten()
.filter_map( Result::ok )
.filter( | e |
{
let name = e.file_name();
let n = name.to_string_lossy();
n.starts_with( "_active_" ) && n != own.as_str()
} )
.filter_map( | e | std::fs::read_to_string( e.path() ).ok() )
.map( | s | s.trim().to_string() )
.filter( | s | !s.is_empty() )
.collect()
}
#[ derive( Debug ) ]
pub enum RenewalOperation
{
At( String ),
Clear,
}
#[ inline ]
pub fn account_renewal(
name : &str,
credential_store : &Path,
op : &RenewalOperation,
dry : bool,
) -> Result< String, std::io::Error >
{
let cred_path = credential_store.join( format!( "{name}.credentials.json" ) );
if !cred_path.exists()
{
return Err( std::io::Error::new(
std::io::ErrorKind::NotFound,
format!( "account '{name}' not found in {}", credential_store.display() ),
) );
}
let meta_path = credential_store.join( format!( "{name}.json" ) );
let existing_str = std::fs::read_to_string( &meta_path )
.unwrap_or_else( |_| "{}".to_string() );
let mut val = serde_json::from_str::< serde_json::Value >( &existing_str )
.unwrap_or_else( |_| serde_json::json!( {} ) );
let obj = val.as_object_mut()
.ok_or_else( || std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!( "{name}.json is not a JSON object" ),
) )?;
let status_str = match op
{
RenewalOperation::At( ts ) =>
{
obj.insert( "_renewal_at".to_string(), serde_json::Value::String( ts.clone() ) );
format!( "set _renewal_at = {ts}" )
}
RenewalOperation::Clear =>
{
obj.remove( "_renewal_at" );
"cleared _renewal_at".to_string()
}
};
if dry
{
return Ok( format!( "[dry-run] {name}: would {status_str}\n" ) );
}
let new_json = serde_json::to_string_pretty( &val )
.map( | s | s + "\n" )
.map_err( |e| std::io::Error::new( std::io::ErrorKind::InvalidData, e.to_string() ) )?;
std::fs::write( &meta_path, new_json )?;
Ok( format!( "{name}: {status_str}\n" ) )
}
#[ doc( hidden ) ]
#[ inline ]
#[ must_use ]
pub fn secs_to_iso8601( secs : u64 ) -> String
{
let sec = secs % 60;
let min = ( secs / 60 ) % 60;
let hour = ( secs / 3600 ) % 24;
let days = secs / 86400;
let mut year = 1970_u64;
let mut d_rem = days;
loop
{
let dy = if is_leap( year ) { 366 } else { 365 };
if d_rem < dy { break; }
d_rem -= dy;
year += 1;
}
let month_days : [ u64; 12 ] =
[ 31, if is_leap( year ) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
let mut month = 0_usize;
while month < 12 && d_rem >= month_days[ month ]
{
d_rem -= month_days[ month ];
month += 1;
}
format!( "{year:04}-{:02}-{:02}T{hour:02}:{min:02}:{sec:02}Z", month + 1, d_rem + 1 )
}
#[ doc( hidden ) ]
#[ inline ]
pub fn parse_from_now_delta( s : &str ) -> Result< i64, String >
{
let s = s.trim();
if s.is_empty() { return Err( "from_now:: value is empty".to_string() ); }
let ( sign, rest ) = match s.chars().next()
{
Some( '+' ) => ( 1_i64, &s[ 1.. ] ),
Some( '-' ) => ( -1_i64, &s[ 1.. ] ),
_ => return Err( format!( "from_now:: must start with '+' or '-', got: '{s}'" ) ),
};
if rest.trim().is_empty()
{
return Err( format!(
"from_now:: '{s}' has no duration components; expected e.g. +1h, +30m, +1d"
) );
}
let mut total_secs = 0_i64;
let mut pos = 0_usize;
let bytes = rest.as_bytes();
while pos < bytes.len()
{
while pos < bytes.len() && bytes[ pos ] == b' ' { pos += 1; }
if pos >= bytes.len() { break; }
let num_start = pos;
while pos < bytes.len() && bytes[ pos ].is_ascii_digit() { pos += 1; }
if pos == num_start
{
return Err( format!( "from_now:: unexpected character '{}' at position {pos}", bytes[ num_start ] as char ) );
}
let num : i64 = rest[ num_start..pos ].parse()
.map_err( |_| "from_now:: numeric overflow".to_string() )?;
if pos >= bytes.len()
{
return Err( format!( "from_now:: missing unit after number {num} (use d, h, or m)" ) );
}
match bytes[ pos ]
{
b'd' => { total_secs += num * 86400; pos += 1; }
b'h' => { total_secs += num * 3600; pos += 1; }
b'm' => { total_secs += num * 60; pos += 1; }
c => return Err( format!( "from_now:: unknown unit '{}' (supported: d, h, m)", c as char ) ),
}
}
Ok( sign * total_secs )
}
fn is_leap( y : u64 ) -> bool
{
( y % 4 == 0 && y % 100 != 0 ) || y % 400 == 0
}
fn read_active_marker( credential_store : &Path ) -> Option< String >
{
let marker = credential_store.join( active_marker_filename() );
std::fs::read_to_string( marker )
.ok()
.map( | s | s.trim().to_string() )
}
#[ doc( hidden ) ]
#[ must_use ]
#[ inline ]
pub fn credential_stem( path : &Path ) -> Option< String >
{
let filename = path.file_name()?.to_str()?;
filename
.strip_suffix( ".credentials.json" )
.map( std::string::ToString::to_string )
}
#[ doc( hidden ) ]
#[ inline ]
pub fn validate_name( name : &str ) -> Result< (), std::io::Error >
{
let at = name.find( '@' ).ok_or_else( || std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!( "account name '{name}' is not a valid email address: must contain '@'" ),
) )?;
if at == 0
{
return Err( std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!( "account name '{name}' is not a valid email address: local part must not be empty" ),
) );
}
if name[ at + 1.. ].is_empty()
{
return Err( std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!( "account name '{name}' is not a valid email address: domain must not be empty" ),
) );
}
let local = &name[ ..at ];
if local.contains( '/' ) || local.contains( '\\' ) || local.contains( '*' )
{
return Err( std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!( "account name '{name}' contains path-unsafe characters in the local part" ),
) );
}
Ok( () )
}
#[ doc( hidden ) ]
#[ must_use ]
#[ inline ]
pub fn parse_string_field( json : &str, key : &str ) -> Option< String >
{
let search = format!( "\"{key}\":" );
let colon_end = json.find( &search )? + search.len();
let rest = json[ colon_end.. ].trim_start();
if !rest.starts_with( '"' ) { return None; }
let inner = &rest[ 1.. ];
let end = inner.find( '"' )?;
Some( inner[ ..end ].to_string() )
}
#[ doc( hidden ) ]
#[ must_use ]
#[ inline ]
pub fn parse_u64_field( json : &str, key : &str ) -> Option< u64 >
{
let search = format!( "\"{key}\":" );
let colon_end = json.find( &search )? + search.len();
let rest = json[ colon_end.. ].trim_start();
let end = rest
.find( | c : char | !c.is_ascii_digit() )
.unwrap_or( rest.len() );
if end == 0 { return None; }
rest[ ..end ].parse().ok()
}
#[ doc( hidden ) ]
#[ must_use ]
#[ inline ]
pub fn parse_string_array_field( json : &str, key : &str ) -> Vec< String >
{
let search = format!( "\"{key}\":" );
let colon_end = match json.find( &search )
{
Some( p ) => p + search.len(),
None => return Vec::new(),
};
let rest = json[ colon_end.. ].trim_start();
if !rest.starts_with( '[' ) { return Vec::new(); }
let end = match rest[ 1.. ].find( ']' )
{
Some( p ) => 1 + p,
None => return Vec::new(),
};
let inner = &rest[ 1..end ];
let mut values = Vec::new();
let mut pos = 0_usize;
while pos < inner.len()
{
let Some( q_start ) = inner[ pos.. ].find( '"' ) else { break };
let start_val = pos + q_start + 1;
let Some( q_end ) = inner[ start_val.. ].find( '"' ) else { break };
let end_val = start_val + q_end;
values.push( inner[ start_val..end_val ].to_string() );
pos = end_val + 1;
}
values
}
#[ doc( hidden ) ]
#[ inline ]
pub fn read_access_token_from_file( path : &std::path::Path ) -> Result< String, String >
{
let content = std::fs::read_to_string( path )
.map_err( |e| format!( "cannot read credentials: {e}" ) )?;
parse_string_field( &content, "accessToken" )
.ok_or_else( || "missing accessToken".to_string() )
}
#[ derive( Debug ) ]
pub struct QuotaCacheEntry
{
pub fetched_at : String,
pub five_hour : Option< ( f64, Option< String > ) >,
pub seven_day : Option< ( f64, Option< String > ) >,
pub seven_day_sonnet : Option< ( f64, Option< String > ) >,
pub model_override : Option< String >,
pub last_touch_at : Option< String >,
pub touch_idle : Option< bool >,
}
#[ inline ]
pub fn write_quota_cache(
credential_store : &std::path::Path,
name : &str,
five_hour : Option< ( f64, Option< &str > ) >,
seven_day : Option< ( f64, Option< &str > ) >,
seven_day_sonnet : Option< ( f64, Option< &str > ) >,
)
{
let meta_path = credential_store.join( format!( "{name}.json" ) );
let mut snapshot = std::fs::read_to_string( &meta_path )
.ok()
.and_then( |s| serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
if let Some( obj ) = snapshot.as_object_mut()
{
let now = chrono_now_utc();
let mut cache = serde_json::json!( { "fetched_at": now, "status": "ok" } );
if let Some( co ) = cache.as_object_mut()
{
if let Some( ( u, r ) ) = five_hour
{
co.insert( "five_hour".into(), period_json( u, r ) );
}
if let Some( ( u, r ) ) = seven_day
{
co.insert( "seven_day".into(), period_json( u, r ) );
}
if let Some( ( u, r ) ) = seven_day_sonnet
{
co.insert( "seven_day_sonnet".into(), period_json( u, r ) );
}
if let Some( prev ) = obj.get( "cache" ).and_then( |c| c.as_object() )
{
if let Some( v ) = prev.get( "model_override" ) { co.insert( "model_override".into(), v.clone() ); }
if let Some( v ) = prev.get( "last_touch_at" ) { co.insert( "last_touch_at".into(), v.clone() ); }
if let Some( v ) = prev.get( "touch_idle" ) { co.insert( "touch_idle".into(), v.clone() ); }
if let Some( v ) = prev.get( "history" ) { co.insert( "history".into(), v.clone() ); }
}
}
obj.insert( "cache".to_string(), cache );
}
let _ = std::fs::write( &meta_path, serde_json::to_string_pretty( &snapshot ).map( | s | s + "\n" ).unwrap_or_default() );
}
#[ inline ]
pub fn read_quota_cache( credential_store : &std::path::Path, name : &str ) -> Option< QuotaCacheEntry >
{
let meta_path = credential_store.join( format!( "{name}.json" ) );
let text = std::fs::read_to_string( &meta_path ).ok()?;
let val : serde_json::Value = serde_json::from_str( &text ).ok()?;
let cache = val.get( "cache" )?.as_object()?;
let fetched_at = cache.get( "fetched_at" )?.as_str()?.to_string();
Some( QuotaCacheEntry
{
fetched_at,
five_hour : read_period( cache, "five_hour" ),
seven_day : read_period( cache, "seven_day" ),
seven_day_sonnet : read_period( cache, "seven_day_sonnet" ),
model_override : cache.get( "model_override" ).and_then( |v| v.as_str() ).map( str::to_string ),
last_touch_at : cache.get( "last_touch_at" ).and_then( |v| v.as_str() ).map( str::to_string ),
touch_idle : cache.get( "touch_idle" ).and_then( serde_json::Value::as_bool ),
} )
}
#[ inline ]
pub fn write_cache_field(
credential_store : &std::path::Path,
name : &str,
key : &str,
value : serde_json::Value,
)
{
let meta_path = credential_store.join( format!( "{name}.json" ) );
let mut snapshot = std::fs::read_to_string( &meta_path )
.ok()
.and_then( |s| serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
if let Some( obj ) = snapshot.as_object_mut()
{
let cache = obj.entry( "cache" ).or_insert_with( || serde_json::json!( {} ) );
if let Some( co ) = cache.as_object_mut()
{
co.insert( key.to_string(), value );
}
}
let _ = std::fs::write( &meta_path, serde_json::to_string_pretty( &snapshot ).map( | s | s + "\n" ).unwrap_or_default() );
}
#[ inline ]
pub fn write_cache_string(
credential_store : &std::path::Path,
name : &str,
key : &str,
value : &str,
)
{
write_cache_field( credential_store, name, key, serde_json::Value::String( value.to_string() ) );
}
#[ inline ]
pub fn write_cache_bool(
credential_store : &std::path::Path,
name : &str,
key : &str,
value : bool,
)
{
write_cache_field( credential_store, name, key, serde_json::Value::Bool( value ) );
}
fn period_json( utilization : f64, resets_at : Option< &str > ) -> serde_json::Value
{
let mut m = serde_json::Map::new();
m.insert( "left_pct".into(), serde_json::Value::from( utilization ) );
if let Some( r ) = resets_at
{
m.insert( "resets_at".into(), serde_json::Value::String( r.to_string() ) );
}
serde_json::Value::Object( m )
}
fn read_period( cache : &serde_json::Map< String, serde_json::Value >, key : &str ) -> Option< ( f64, Option< String > ) >
{
let p = cache.get( key )?.as_object()?;
let left_pct = p.get( "left_pct" )?.as_f64()?;
let resets_at = p.get( "resets_at" ).and_then( |v| v.as_str() ).map( str::to_string );
Some( ( left_pct, resets_at ) )
}
#[ must_use ]
#[ inline ]
pub fn chrono_now_utc() -> String
{
use std::time::{ SystemTime, UNIX_EPOCH };
let secs = SystemTime::now().duration_since( UNIX_EPOCH ).unwrap_or_default().as_secs();
#[ allow( clippy::cast_possible_wrap ) ]
let days = ( secs / 86400 ) as i64;
let tod = secs % 86400;
let hh = tod / 3600;
let mm = ( tod % 3600 ) / 60;
let ss = tod % 60;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = ( doe - doe / 1460 + doe / 36524 - doe / 146_096 ) / 365;
let y = yoe + era * 400;
let doy = doe - ( 365 * yoe + yoe / 4 - yoe / 100 );
let mp = ( 5 * doy + 2 ) / 153;
let d = doy - ( 153 * mp + 2 ) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!( "{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z" )
}
#[ must_use ]
#[ inline ]
pub fn parse_iso_utc_secs( s : &str ) -> Option< u64 >
{
if s.len() < 20 || !s.ends_with( 'Z' ) { return None; }
let y : i64 = s[ 0..4 ].parse().ok()?;
let m : i64 = s[ 5..7 ].parse().ok()?;
let d : i64 = s[ 8..10 ].parse().ok()?;
let hh : u64 = s[ 11..13 ].parse().ok()?;
let mm : u64 = s[ 14..16 ].parse().ok()?;
let ss : u64 = s[ 17..19 ].parse().ok()?;
let y2 = if m <= 2 { y - 1 } else { y };
let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
let yoe = y2 - era * 400;
let m2 = if m > 2 { m - 3 } else { m + 9 };
let doy = ( 153 * m2 + 2 ) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
#[ allow( clippy::cast_sign_loss ) ]
let days = ( era * 146_097 + doe - 719_468 ) as u64;
Some( days * 86400 + hh * 3600 + mm * 60 + ss )
}
#[ derive( Debug ) ]
pub struct HistoryEntry
{
pub t : u64,
pub h5 : Option< ( f64, String ) >,
pub d7 : Option< ( f64, String ) >,
pub sn : Option< ( f64, String ) >,
}
fn parse_history_period( val : &serde_json::Value ) -> Option< ( f64, String ) >
{
let arr = val.as_array()?;
if arr.len() != 2 { return None; }
let u = arr[ 0 ].as_f64()?;
let r = arr[ 1 ].as_str()?.to_string();
Some( ( u, r ) )
}
#[ must_use ]
#[ inline ]
pub fn read_history(
credential_store : &std::path::Path,
name : &str,
) -> Vec< HistoryEntry >
{
let meta_path = credential_store.join( format!( "{name}.json" ) );
let Ok( text ) = std::fs::read_to_string( &meta_path ) else { return vec![] };
let val : serde_json::Value = match serde_json::from_str( &text ) { Ok( v ) => v, Err( _ ) => return vec![] };
let Some( arr ) = val.get( "cache" ).and_then( |c| c.get( "history" ) ).and_then( |h| h.as_array() ) else { return vec![] };
arr.iter().filter_map( |entry|
{
let t = entry.get( "t" )?.as_u64()?;
Some( HistoryEntry
{
t,
h5 : entry.get( "h5" ).and_then( parse_history_period ),
d7 : entry.get( "d7" ).and_then( parse_history_period ),
sn : entry.get( "sn" ).and_then( parse_history_period ),
} )
} ).collect()
}
fn history_period_json( period : Option< ( f64, &str ) > ) -> serde_json::Value
{
match period
{
Some( ( u, r ) ) => serde_json::json!( [ u, r ] ),
None => serde_json::Value::Null,
}
}
#[ inline ]
pub fn write_history_entry(
credential_store : &std::path::Path,
name : &str,
t : u64,
h5 : Option< ( f64, &str ) >,
d7 : Option< ( f64, &str ) >,
sn : Option< ( f64, &str ) >,
)
{
let meta_path = credential_store.join( format!( "{name}.json" ) );
let mut snapshot = std::fs::read_to_string( &meta_path )
.ok()
.and_then( |s| serde_json::from_str::< serde_json::Value >( &s ).ok() )
.unwrap_or_else( || serde_json::json!( {} ) );
if let Some( obj ) = snapshot.as_object_mut()
{
let cache = obj.entry( "cache" ).or_insert_with( || serde_json::json!( {} ) );
if let Some( co ) = cache.as_object_mut()
{
let history = co.entry( "history" ).or_insert_with( || serde_json::json!( [] ) );
if let Some( arr ) = history.as_array_mut()
{
let entry = serde_json::json!(
{
"t" : t,
"h5" : history_period_json( h5 ),
"d7" : history_period_json( d7 ),
"sn" : history_period_json( sn ),
} );
if let Some( last ) = arr.last()
{
if last.get( "t" ).and_then( serde_json::Value::as_u64 ) == Some( t )
{
let len = arr.len();
arr[ len - 1 ] = entry;
}
else
{
arr.push( entry );
}
}
else
{
arr.push( entry );
}
while arr.len() > 10
{
arr.remove( 0 );
}
}
}
}
let _ = std::fs::write(
&meta_path,
serde_json::to_string_pretty( &snapshot ).map( |s| s + "\n" ).unwrap_or_default(),
);
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn ft08_parse_string_array_field_two_elements()
{
let json = r#"{"capabilities":["claude_max","chat"]}"#;
let result = parse_string_array_field( json, "capabilities" );
assert_eq!( result, vec![ "claude_max", "chat" ] );
}
#[ test ]
fn ft08_parse_string_array_field_missing_key_returns_empty()
{
let json = r#"{"other_field":"value"}"#;
let result = parse_string_array_field( json, "capabilities" );
assert!( result.is_empty(), "missing key must return empty Vec, got: {result:?}" );
}
#[ test ]
fn ft08_parse_string_array_field_empty_array_returns_empty()
{
let json = r#"{"capabilities":[]}"#;
let result = parse_string_array_field( json, "capabilities" );
assert!( result.is_empty(), "empty array must return empty Vec, got: {result:?}" );
}
}