use claude_common::ClaudePaths;
use claude_common::process::find_claude_processes;
use crate::settings_io::{ get_setting, set_setting, set_env_var, remove_env_var };
use crate::CoreError;
const INSTALL_URL : &str = "https://claude.ai/install.sh";
#[ derive( Debug ) ]
pub struct VersionAlias
{
pub name : &'static str,
pub value : &'static str,
pub description : &'static str,
}
pub const VERSION_ALIASES : &[ VersionAlias ] = &[
VersionAlias { name : "latest", value : "", description : "Most recent published release" },
VersionAlias { name : "stable", value : "2.1.78", description : "Pinned stable release (recommended)" },
VersionAlias { name : "month", value : "2.1.74", description : "~1 month old release for stability" },
];
#[ inline ]
#[ must_use ]
pub fn extract_semver( raw : &str ) -> &str
{
raw.split_whitespace()
.find_map( | t |
{
let candidate = t.strip_prefix( 'v' )
.or_else( || t.strip_prefix( 'V' ) )
.unwrap_or( t );
if !candidate.is_empty() && candidate.chars().all( | c | c.is_ascii_digit() || c == '.' )
{
Some( candidate )
}
else
{
None
}
} )
.unwrap_or( raw )
}
#[ inline ]
#[ must_use ]
pub fn get_version_from_symlink() -> Option< String >
{
let home = std::env::var( "HOME" ).ok().filter( | h | !h.is_empty() )?;
let link = format!( "{home}/.local/bin/claude" );
let target = std::fs::read_link( &link ).ok()?;
let name = target.file_name()?.to_str()?;
if !name.is_empty() && name.chars().all( | c | c.is_ascii_digit() || c == '.' )
{
Some( name.to_string() )
}
else
{
None
}
}
#[ inline ]
#[ must_use ]
pub fn get_claude_version_raw() -> Option< String >
{
let output = std::process::Command::new( "bash" )
.args( [ "-c", "claude --version" ] )
.env( "DISABLE_AUTOUPDATER", "1" )
.output()
.ok()?;
let s = String::from_utf8_lossy( &output.stdout ).trim().to_string();
if s.is_empty() { None } else { Some( s ) }
}
#[ inline ]
#[ must_use ]
pub fn get_installed_version() -> Option< String >
{
get_version_from_symlink()
.or_else( ||
{
get_claude_version_raw().map( | raw | extract_semver( &raw ).to_string() )
} )
}
#[ inline ]
#[ must_use ]
pub fn resolve_version_spec( spec : &str ) -> &str
{
VERSION_ALIASES.iter()
.find( | a | a.name == spec )
.map_or( spec, | a | if a.value.is_empty() { a.name } else { a.value } )
}
#[ inline ]
pub fn validate_version_spec( spec : &str ) -> Result< (), CoreError >
{
if spec.is_empty()
{
return Err( CoreError::ParseError( "version:: value cannot be empty".to_string() ) );
}
if VERSION_ALIASES.iter().any( | a | a.name == spec )
{
return Ok( () );
}
let parts : Vec< &str > = spec.split( '.' ).collect();
if parts.len() == 3
&& parts.iter().all( | p |
{
!p.is_empty()
&& p.chars().all( | c | c.is_ascii_digit() )
&& ( p.len() == 1 || !p.starts_with( '0' ) )
} )
{
return Ok( () );
}
Err( CoreError::ParseError( format!(
"unknown version '{spec}': expected 'stable', 'latest', 'month', or semver like '1.2.3'"
) ) )
}
#[ inline ]
pub fn hot_swap_binary()
{
let claude_path = std::process::Command::new( "which" )
.arg( "claude" )
.output()
.ok()
.filter( | o | o.status.success() )
.map_or_else(
||
{
let home = std::env::var( "HOME" ).unwrap_or_default();
format!( "{home}/.local/bin/claude" )
},
| o | String::from_utf8_lossy( &o.stdout ).trim().to_string(),
);
if std::path::Path::new( &claude_path ).exists()
{
let _ = std::fs::remove_file( &claude_path );
}
}
#[ inline ]
#[ must_use ]
pub fn versions_dir_path() -> String
{
let home = std::env::var( "HOME" ).unwrap_or_default();
format!( "{home}/.local/share/claude/versions" )
}
#[ inline ]
pub fn purge_stale_versions( versions_dir : &str, keep : &str )
{
let Ok( entries ) = std::fs::read_dir( versions_dir ) else { return; };
for entry in entries.flatten()
{
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str == keep { continue; }
if !name_str.chars().all( | c | c.is_ascii_digit() || c == '.' ) { continue; }
let _ = std::fs::remove_file( entry.path() );
}
}
#[ inline ]
pub fn unlock_versions_dir()
{
let dir = versions_dir_path();
if std::path::Path::new( &dir ).exists()
{
let _ = std::process::Command::new( "chmod" )
.args( [ "755", &dir ] )
.status();
}
}
#[ inline ]
pub fn lock_version( is_latest : bool )
{
if let Some( paths ) = ClaudePaths::new()
{
let settings_file = paths.settings_file();
if let Some( parent ) = settings_file.parent()
{
let _ = std::fs::create_dir_all( parent );
}
let auto_val = if is_latest { "true" } else { "false" };
let _ = set_setting( &settings_file, "autoUpdates", auto_val );
if is_latest
{
let _ = remove_env_var( &settings_file, "DISABLE_AUTOUPDATER" );
}
else
{
let _ = set_env_var( &settings_file, "DISABLE_AUTOUPDATER", "1" );
}
}
let dir = versions_dir_path();
if std::path::Path::new( &dir ).exists()
{
let mode = if is_latest { "755" } else { "555" };
let _ = std::process::Command::new( "chmod" )
.args( [ mode, &dir ] )
.status();
}
}
#[ inline ]
pub fn perform_install( resolved : &str, is_latest : bool ) -> Result< (), CoreError >
{
if !find_claude_processes().is_empty()
{
hot_swap_binary();
}
unlock_versions_dir();
let shell_cmd = if is_latest
{
format!( "curl -fsSL {INSTALL_URL} | bash" )
}
else
{
format!( "curl -fsSL {INSTALL_URL} | bash -s -- {resolved}" )
};
let status = std::process::Command::new( "bash" )
.args( [ "-c", &shell_cmd ] )
.env( "DISABLE_AUTOUPDATER", "1" )
.status()
.map_err( | e | CoreError::ProcessError( format!( "failed to run installer: {e}" ) ) )?;
if !status.success()
{
return Err( CoreError::ProcessError( "install failed".to_string() ) );
}
if !is_latest
{
purge_stale_versions( &versions_dir_path(), resolved );
}
lock_version( is_latest );
Ok( () )
}
#[ inline ]
#[ must_use ]
pub fn read_preferred_version() -> Option< ( String, Option< String > ) >
{
let paths = ClaudePaths::new()?;
let settings_file = paths.settings_file();
let spec = get_setting( &settings_file, "preferredVersionSpec" )
.ok()?
.filter( | s | !s.is_empty() )?;
let resolved = get_setting( &settings_file, "preferredVersionResolved" )
.ok()
.flatten()
.filter( | v | v != "null" && !v.is_empty() );
Some( ( spec, resolved ) )
}
#[ inline ]
pub fn store_preferred_version( spec : &str, resolved : &str, is_latest : bool ) -> Result< (), CoreError >
{
let paths = ClaudePaths::new().ok_or_else( ||
CoreError::ProcessError( "HOME environment variable not set".to_string() )
)?;
let settings_file = paths.settings_file();
if let Some( parent ) = settings_file.parent()
{
let _ = std::fs::create_dir_all( parent );
}
set_setting( &settings_file, "preferredVersionSpec", spec )
.map_err( CoreError::IoError )?;
let resolved_val = if is_latest { "null" } else { resolved };
set_setting( &settings_file, "preferredVersionResolved", resolved_val )
.map_err( CoreError::IoError )?;
Ok( () )
}