use std::path::Path;
use claude_common::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,
}
#[ inline ]
#[ must_use = "check the returned accounts list" ]
pub fn list() -> Result< Vec< Account >, std::io::Error >
{
let paths = require_paths()?;
let accounts_dir = paths.accounts_dir();
if !accounts_dir.exists() { return Ok( Vec::new() ); }
let active = read_active_marker( &accounts_dir );
let mut accounts = Vec::new();
for entry in std::fs::read_dir( &accounts_dir )?.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() );
accounts.push( Account { name, subscription_type, rate_limit_tier, expires_at_ms, is_active } );
}
accounts.sort_by( | a, b | a.name.cmp( &b.name ) );
Ok( accounts )
}
#[ inline ]
pub fn save( name : &str ) -> Result< (), std::io::Error >
{
validate_name( name )?;
let paths = require_paths()?;
let accounts_dir = paths.accounts_dir();
std::fs::create_dir_all( &accounts_dir )?;
let dest = accounts_dir.join( format!( "{name}.credentials.json" ) );
std::fs::copy( paths.credentials_file(), dest )?;
Ok( () )
}
#[ inline ]
pub fn check_switch_preconditions( name : &str ) -> Result< (), std::io::Error >
{
validate_name( name )?;
let paths = require_paths()?;
let src = paths.accounts_dir().join( format!( "{name}.credentials.json" ) );
if !src.exists()
{
return Err( std::io::Error::new(
std::io::ErrorKind::NotFound,
format!( "account '{name}' not found in {}", paths.accounts_dir().display() ),
) );
}
Ok( () )
}
#[ inline ]
pub fn switch_account( name : &str ) -> Result< (), std::io::Error >
{
check_switch_preconditions( name )?;
let paths = require_paths()?;
let accounts_dir = paths.accounts_dir();
let src = accounts_dir.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 = accounts_dir.join( "_active" );
std::fs::write( marker, name )?;
Ok( () )
}
#[ inline ]
pub fn auto_rotate() -> Result< String, std::io::Error >
{
let candidate = list()?
.into_iter()
.filter( | a | !a.is_active )
.max_by_key( | a | a.expires_at_ms )
.ok_or_else( || std::io::Error::new(
std::io::ErrorKind::NotFound,
"no inactive account available to rotate to",
) )?;
let name = candidate.name;
switch_account( &name )?;
Ok( name )
}
#[ inline ]
pub fn check_delete_preconditions( name : &str ) -> Result< (), std::io::Error >
{
validate_name( name )?;
let paths = require_paths()?;
let accounts_dir = paths.accounts_dir();
let active = read_active_marker( &accounts_dir );
if active.as_deref() == Some( name )
{
return Err( std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!( "cannot delete active account '{name}' — switch to another account first" ),
) );
}
let target = accounts_dir.join( format!( "{name}.credentials.json" ) );
if !target.exists()
{
return Err( std::io::Error::new(
std::io::ErrorKind::NotFound,
format!( "account '{name}' not found in {}", accounts_dir.display() ),
) );
}
Ok( () )
}
#[ inline ]
pub fn delete( name : &str ) -> Result< (), std::io::Error >
{
check_delete_preconditions( name )?;
let paths = require_paths()?;
let accounts_dir = paths.accounts_dir();
let target = accounts_dir.join( format!( "{name}.credentials.json" ) );
std::fs::remove_file( target )?;
Ok( () )
}
fn require_paths() -> Result< ClaudePaths, std::io::Error >
{
ClaudePaths::new().ok_or_else( || std::io::Error::new(
std::io::ErrorKind::NotFound,
"HOME environment variable not set",
) )
}
fn read_active_marker( accounts_dir : &Path ) -> Option< String >
{
let marker = accounts_dir.join( "_active" );
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 >
{
if name.is_empty()
{
return Err( std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"account name must not be empty",
) );
}
if name.chars().any( | c | matches!( c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' ) )
{
return Err( std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!( "account name '{name}' contains invalid characters" ),
) );
}
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()
}