use core::fmt::Write as _;
use unilang::data::{ ErrorCode, ErrorData, OutputData };
use unilang::interpreter::ExecutionContext;
use unilang::semantic::VerifiedCommand;
use unilang::types::Value;
use crate::output::{ OutputFormat, OutputOptions, json_escape };
use claude_runner_core::process::{ ProcessInfo, find_claude_processes, send_sigkill, send_sigterm };
use claude_version_core::settings_io::{ StoredAs, get_setting, infer_type, read_all_settings, set_setting };
use claude_version_core::version::{
VERSION_ALIASES,
extract_semver, get_claude_version_raw, get_installed_version,
perform_install, read_preferred_version,
resolve_version_spec, store_preferred_version,
validate_version_spec,
};
fn require_nonempty_string_arg( cmd : &VerifiedCommand, name : &str ) -> Result< String, ErrorData >
{
let val = match cmd.arguments.get( name )
{
Some( Value::String( s ) ) => s.clone(),
_ => return Err( ErrorData::new( ErrorCode::ArgumentMissing, format!( "{name}:: is required" ) ) ),
};
if val.is_empty()
{
return Err( ErrorData::new( ErrorCode::ArgumentMissing, format!( "{name}:: value cannot be empty" ) ) );
}
Ok( val )
}
#[ inline ]
fn is_dry( cmd : &VerifiedCommand ) -> bool
{
matches!( cmd.arguments.get( "dry" ), Some( Value::Boolean( true ) ) )
}
#[ inline ]
fn is_force( cmd : &VerifiedCommand ) -> bool
{
matches!( cmd.arguments.get( "force" ), Some( Value::Boolean( true ) ) )
}
fn require_claude_paths() -> Result< claude_core::ClaudePaths, ErrorData >
{
match std::env::var( "HOME" )
{
Ok( home ) if !home.is_empty() =>
{
claude_core::ClaudePaths::new().ok_or_else( || ErrorData::new(
ErrorCode::InternalError,
"could not resolve Claude configuration paths (HOME is set but path resolution failed)".to_string(),
) )
}
Ok( _ ) => Err( ErrorData::new( ErrorCode::InternalError, "HOME environment variable is empty".to_string() ) ),
Err( _ ) => Err( ErrorData::new( ErrorCode::InternalError, "HOME environment variable not set".to_string() ) ),
}
}
fn get_active_account() -> Option< String >
{
let root = std::env::var_os( "PRO" )
.filter( | v | !v.is_empty() )
.or_else( || std::env::var_os( "HOME" ).filter( | v | !v.is_empty() ) )?;
let marker = std::path::Path::new( &root )
.join( ".persistent" ).join( "claude" ).join( "credential" ).join( "_active" );
std::fs::read_to_string( marker )
.ok()
.map( | s | s.trim().to_string() )
.filter( | s | !s.is_empty() )
}
const RELEASES_API_URL : &str = "https://api.github.com/repos/anthropics/claude-code/releases?per_page=100";
const CACHE_TTL_SECS : u64 = 3600;
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn status_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let version = get_installed_version()
.or_else( || get_claude_version_raw().map( | r | extract_semver( &r ).to_string() ) )
.unwrap_or_else( || "not found".to_string() );
let processes = find_claude_processes().len();
let account = get_active_account().unwrap_or_else( || "unknown".to_string() );
let pref = read_preferred_version();
let content = match ( opts.format, opts.verbosity )
{
( OutputFormat::Json, _ ) =>
{
let v = json_escape( &version );
let a = json_escape( &account );
match &pref
{
Some( ( spec, resolved ) ) =>
{
let ps = json_escape( spec );
let pr = resolved.as_deref().map_or( "null".to_string(), | r | format!( "\"{}\"", json_escape( r ) ) );
format!( "{{\"version\":\"{v}\",\"processes\":{processes},\"account\":\"{a}\",\"preferred\":{{\"spec\":\"{ps}\",\"resolved\":{pr}}}}}\n" )
}
None => format!( "{{\"version\":\"{v}\",\"processes\":{processes},\"account\":\"{a}\"}}\n" ),
}
}
( OutputFormat::Text, 0 ) =>
{
match &pref
{
Some( ( spec, _ ) ) => format!( "{version}\n{processes}\n{account}\n{spec}\n" ),
None => format!( "{version}\n{processes}\n{account}\n" ),
}
}
( OutputFormat::Text, v ) =>
{
let base = format!( "Version: {version}\nProcesses: {processes}\nAccount: {account}" );
match &pref
{
Some( ( spec, resolved ) ) =>
{
let pref_str = match resolved
{
Some( r ) => format!( "{spec} (v{r})" ),
None => spec.clone(),
};
if v >= 2
{
format!( "{base}\nPreferred: {pref_str} (settings.json \u{2192} preferredVersionSpec)\n" )
}
else
{
format!( "{base}\nPreferred: {pref_str}\n" )
}
}
None => format!( "{base}\n" ),
}
}
};
Ok( OutputData::new( content, "text" ) )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn version_show_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let version = get_installed_version().ok_or_else( || ErrorData::new(
ErrorCode::InternalError,
"claude binary not found in PATH".to_string(),
) )?;
let pref = read_preferred_version();
let content = match ( opts.format, opts.verbosity )
{
( OutputFormat::Json, _ ) =>
{
let v = json_escape( &version );
format!( "{{\"version\":\"{v}\"}}\n" )
}
( OutputFormat::Text, 0 ) => format!( "{version}\n" ),
( OutputFormat::Text, _ ) =>
{
let mut out = format!( "Version: {version}\n" );
if let Some( ( spec, resolved ) ) = &pref
{
let pref_str = match resolved
{
Some( r ) => format!( "{spec} (v{r})" ),
None => spec.clone(),
};
let match_status = match resolved
{
Some( r ) if r == &version => "match",
Some( _ ) => "MISMATCH",
None => "latest",
};
let _ = writeln!( out, "Preferred: {pref_str} -- {match_status}" );
}
out
}
};
Ok( OutputData::new( content, "text" ) )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn version_install_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let version_spec = match cmd.arguments.get( "version" )
{
Some( Value::String( s ) ) => s.clone(),
_ => "stable".to_string(),
};
validate_version_spec( &version_spec )
.map_err( | e | ErrorData::new( ErrorCode::ArgumentTypeMismatch, e.to_string() ) )?;
let resolved = resolve_version_spec( &version_spec );
let is_latest = resolved == "latest";
let is_alias = version_spec != resolved;
let label = if is_alias { format!( "{version_spec} (v{resolved})" ) }
else if is_latest { "latest".to_string() }
else { format!( "v{resolved}" ) };
let auto_label = if is_latest { "true" } else { "false" };
if is_dry( &cmd )
{
let content = install_dry_content( &opts, &label, auto_label, is_latest, &version_spec, resolved );
return Ok( OutputData::new( content, "text" ) );
}
if !is_force( &cmd ) && !is_latest
{
if let Some( current ) = get_installed_version()
{
if current == resolved
{
let _ = store_preferred_version( &version_spec, resolved, is_latest );
let content = match opts.format
{
OutputFormat::Json =>
{
let l = json_escape( &label );
format!( "{{\"installed\":false,\"label\":\"{l}\"}}\n" )
}
OutputFormat::Text =>
{
if opts.verbosity == 0
{
format!( "{label}\n" )
}
else
{
format!( "already at {label}\n" )
}
}
};
return Ok( OutputData::new( content, "text" ) );
}
}
}
perform_install( resolved, is_latest )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e.to_string() ) )?;
store_preferred_version( &version_spec, resolved, is_latest )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e.to_string() ) )?;
let pref_label = if is_latest { version_spec.clone() } else { format!( "{version_spec} (v{resolved})" ) };
let content = match opts.format
{
OutputFormat::Json =>
{
let l = json_escape( &label );
let p = json_escape( &pref_label );
format!( "{{\"installed\":true,\"label\":\"{l}\",\"auto_updates\":{auto_label},\"preferred\":\"{p}\"}}\n" )
}
OutputFormat::Text =>
{
if opts.verbosity == 0
{
format!( "{label}\n" )
}
else
{
format!( "installed {label}\nautoUpdates = {auto_label}\npreferred = {pref_label}\n" )
}
}
};
Ok( OutputData::new( content, "text" ) )
}
fn install_dry_content(
opts : &OutputOptions,
label : &str,
auto_label : &str,
is_latest : bool,
version_spec : &str,
resolved : &str,
) -> String
{
match opts.format
{
OutputFormat::Json =>
{
let l = json_escape( label );
format!( "{{\"dry_run\":true,\"version\":\"{l}\",\"auto_updates\":{auto_label}}}\n" )
}
OutputFormat::Text =>
{
if is_latest
{
format!(
"[dry-run] would install {label}\n\
[dry-run] would set autoUpdates = {auto_label}\n\
[dry-run] would remove env.DISABLE_AUTOUPDATER\n\
[dry-run] would leave versions dir unlocked\n\
[dry-run] would store preferred version = {version_spec}\n"
)
}
else
{
format!(
"[dry-run] would install {label}\n\
[dry-run] would set autoUpdates = {auto_label}\n\
[dry-run] would set env.DISABLE_AUTOUPDATER = 1\n\
[dry-run] would chmod 555 versions dir (hard lock)\n\
[dry-run] would purge stale cached binaries (keep v{resolved})\n\
[dry-run] would store preferred version = {version_spec} (v{resolved})\n"
)
}
}
}
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn version_guard_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let dry = is_dry( &cmd );
let force = is_force( &cmd );
let version_override = match cmd.arguments.get( "version" )
{
Some( Value::String( s ) ) =>
{
validate_version_spec( s )
.map_err( | e | ErrorData::new( ErrorCode::ArgumentTypeMismatch, e.to_string() ) )?;
Some( s.clone() )
}
_ => None,
};
let interval_secs = match cmd.arguments.get( "interval" )
{
Some( Value::Integer( n ) ) => u64::try_from( *n ).unwrap_or( 0 ),
_ => 0,
};
if interval_secs == 0
{
return guard_once( dry, force, version_override.as_deref(), opts.verbosity, opts.format );
}
let mut iterations : u64 = 0;
loop
{
iterations += 1;
let now = current_timestamp();
let result = guard_once( dry, force, version_override.as_deref(), opts.verbosity, opts.format );
match &result
{
Ok( out ) =>
{
let status = out.content.trim_end();
eprintln!( "[{now}] #{iterations} {status}" );
}
Err( e ) =>
{
eprintln!( "[{now}] #{iterations} error: {e}" );
}
}
std::thread::sleep( core::time::Duration::from_secs( interval_secs ) );
}
}
fn guard_once( dry : bool, force : bool, version_override : Option< &str >, verbosity : u8, format : OutputFormat ) -> Result< OutputData, ErrorData >
{
let home_valid = std::env::var( "HOME" ).is_ok_and( | h | !h.is_empty() );
if !home_valid
{
let msg = match format
{
OutputFormat::Json => "{\"status\":\"no-home\"}\n".to_string(),
OutputFormat::Text =>
{
if verbosity == 0
{
"no-home\n".to_string()
}
else
{
"no HOME directory; defaulting to stable (nothing to guard)\n".to_string()
}
}
};
return Ok( OutputData::new( msg, "text" ) );
}
let ( spec, resolved ) = if let Some( ver ) = version_override
{
let resolved_ver = resolve_version_spec( ver );
let resolved_opt = if resolved_ver == ver { None } else { Some( resolved_ver.to_string() ) };
( ver.to_string(), resolved_opt.or_else( || Some( ver.to_string() ) ) )
}
else
{
read_preferred_version()
.unwrap_or_else( || ( "stable".to_string(), Some( resolve_version_spec( "stable" ).to_string() ) ) )
};
if spec == "latest" || resolved.is_none()
{
return guard_once_latest( dry, verbosity, format );
}
guard_once_pinned( dry, force, &spec, resolved.as_deref().unwrap_or( &spec ), verbosity, format )
}
fn guard_once_latest( dry : bool, verbosity : u8, format : OutputFormat ) -> Result< OutputData, ErrorData >
{
if dry
{
let msg = match format
{
OutputFormat::Json => "{\"status\":\"dry\",\"spec\":\"latest\"}\n".to_string(),
OutputFormat::Text =>
{
if verbosity == 0 { "latest\n" } else { "preferred = latest (no version pin to guard)\n" }.to_string()
}
};
return Ok( OutputData::new( msg, "text" ) );
}
if let Some( paths ) = claude_core::ClaudePaths::new()
{
let settings_file = paths.settings_file();
let auto_val = get_setting( &settings_file, "autoUpdates" )
.ok()
.flatten()
.unwrap_or_default();
if auto_val != "true"
{
set_setting( &settings_file, "autoUpdates", "true" )
.map_err( | e | ErrorData::new(
ErrorCode::InternalError,
format!( "failed to set autoUpdates: {e}" ),
) )?;
let msg = match format
{
OutputFormat::Json => "{\"status\":\"fixed\",\"action\":\"autoUpdates_enabled\"}\n".to_string(),
OutputFormat::Text =>
{
if verbosity == 0 { "fixed\n" } else { "fixed autoUpdates = true for latest preference\n" }.to_string()
}
};
return Ok( OutputData::new( msg, "text" ) );
}
}
let msg = match format
{
OutputFormat::Json => "{\"status\":\"ok\",\"spec\":\"latest\"}\n".to_string(),
OutputFormat::Text =>
{
if verbosity == 0 { "latest\n" } else { "preferred = latest (auto-update enabled)\n" }.to_string()
}
};
Ok( OutputData::new( msg, "text" ) )
}
fn check_installed_guard(
target : &str,
pref_label : &str,
dry : bool,
verbosity : u8,
format : OutputFormat,
) -> Result< Option< OutputData >, ErrorData >
{
let Some( current ) = get_installed_version() else { return Ok( None ); };
if current == target
{
let pl = json_escape( pref_label );
let msg = match format
{
OutputFormat::Json =>
{
format!( "{{\"status\":\"ok\",\"installed\":\"{current}\",\"preferred\":\"{pl}\"}}\n" )
}
OutputFormat::Text =>
{
if verbosity == 0
{
"ok\n".to_string()
}
else
{
format!( "version {current} matches preferred {pref_label}\n" )
}
}
};
return Ok( Some( OutputData::new( msg, "text" ) ) );
}
if dry
{
let pl = json_escape( pref_label );
let msg = match format
{
OutputFormat::Json =>
{
format!( "{{\"status\":\"dry\",\"drift\":true,\"installed\":\"{current}\",\"preferred\":\"{pl}\"}}\n" )
}
OutputFormat::Text =>
{
if verbosity == 0
{
format!( "[dry-run] {current}\u{2192}{target}\n" )
}
else
{
format!( "[dry-run] drift detected: installed {current}, preferred {pref_label}\n\
[dry-run] would reinstall {pref_label}\n" )
}
}
};
return Ok( Some( OutputData::new( msg, "text" ) ) );
}
eprintln!( "drift detected: installed {current}, preferred {pref_label} \u{2014} restoring" );
perform_install( target, false )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e.to_string() ) )?;
let pl = json_escape( pref_label );
let msg = match format
{
OutputFormat::Json =>
{
format!( "{{\"status\":\"restored\",\"preferred\":\"{pl}\"}}\n" )
}
OutputFormat::Text =>
{
if verbosity == 0 { format!( "restored {target}\n" ) } else { format!( "restored {pref_label}\n" ) }
}
};
Ok( Some( OutputData::new( msg, "text" ) ) )
}
fn guard_once_pinned( dry : bool, force : bool, spec : &str, resolved : &str, verbosity : u8, format : OutputFormat ) -> Result< OutputData, ErrorData >
{
let resolved_now = resolve_version_spec( spec );
let target = if resolved_now == spec { resolved } else { resolved_now };
let pref_label = if spec == target { format!( "v{target}" ) } else { format!( "{spec} (v{target})" ) };
if !force
{
if let Some( output ) = check_installed_guard( target, &pref_label, dry, verbosity, format )?
{
return Ok( output );
}
}
if dry
{
let pl = json_escape( &pref_label );
let msg = match format
{
OutputFormat::Json =>
{
format!( "{{\"status\":\"dry\",\"drift\":false,\"preferred\":\"{pl}\"}}\n" )
}
OutputFormat::Text =>
{
if verbosity == 0
{
format!( "[dry-run] {target}\n" )
}
else
{
format!( "[dry-run] would install preferred {pref_label}\n" )
}
}
};
return Ok( OutputData::new( msg, "text" ) );
}
perform_install( target, false )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e.to_string() ) )?;
let pl = json_escape( &pref_label );
let msg = match format
{
OutputFormat::Json =>
{
format!( "{{\"status\":\"installed\",\"preferred\":\"{pl}\"}}\n" )
}
OutputFormat::Text =>
{
if verbosity == 0 { format!( "installed {target}\n" ) } else { format!( "installed preferred {pref_label}\n" ) }
}
};
Ok( OutputData::new( msg, "text" ) )
}
fn days_to_ymd( mut days : u64 ) -> ( u64, u8, u8 )
{
let y400 = days / 146_097; days %= 146_097;
let y100 = ( days / 36_524 ).min( 3 ); days -= y100 * 36_524;
let y4 = days / 1_461; days %= 1_461;
let y1 = ( days / 365 ).min( 3 ); days -= y1 * 365;
let year = 1970 + y400 * 400 + y100 * 100 + y4 * 4 + y1;
let leap = ( year % 4 == 0 && year % 100 != 0 ) || year % 400 == 0;
let mdays : &[ u64 ] = if leap
{
&[ 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]
}
else
{
&[ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]
};
let mut month = 1u8;
for &md in mdays
{
if days < md { break; }
days -= md;
month += 1;
}
( year, month, u8::try_from( days ).expect( "day of month always 0-30" ) + 1 )
}
fn current_timestamp() -> String
{
let secs = std::time::SystemTime::now()
.duration_since( std::time::UNIX_EPOCH )
.unwrap_or_default()
.as_secs();
let s = secs % 60;
let m = ( secs / 60 ) % 60;
let h = ( secs / 3600 ) % 24;
let ( year, month, day ) = days_to_ymd( secs / 86_400 );
format!( "{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z" )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn version_list_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let content = match ( opts.format, opts.verbosity )
{
( OutputFormat::Json, _ ) =>
{
let entries : Vec< String > = VERSION_ALIASES.iter().map( | a |
{
if a.value.is_empty()
{
format!( " {{\"name\":\"{}\",\"description\":\"{}\"}}", a.name, a.description )
}
else
{
format!( " {{\"name\":\"{}\",\"value\":\"{}\",\"description\":\"{}\"}}", a.name, a.value, a.description )
}
} ).collect();
format!( "[\n{}\n]\n", entries.join( ",\n" ) )
}
( OutputFormat::Text, 0 ) =>
{
let names : Vec< &str > = VERSION_ALIASES.iter().map( | a | a.name ).collect();
format!( "{}\n", names.join( "\n" ) )
}
( OutputFormat::Text, _ ) =>
{
let lines : Vec< String > = VERSION_ALIASES.iter()
.map( | a |
{
if a.value.is_empty()
{
format!( "{} \u{2014} {}", a.name, a.description )
}
else
{
format!( "{} \u{2014} {} (v{})", a.name, a.description, a.value )
}
} )
.collect();
format!( "{}\n", lines.join( "\n" ) )
}
};
Ok( OutputData::new( content, "text" ) )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn processes_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let procs = find_claude_processes();
let content = match opts.format
{
OutputFormat::Json =>
{
if procs.is_empty()
{
"{\"processes\":[]}\n".to_string()
}
else
{
let entries : Vec< String > = procs.iter().map( | p |
{
let cwd = json_escape( &p.cwd.to_string_lossy() );
format!( " {{\"pid\":{},\"cwd\":\"{cwd}\"}}", p.pid )
} ).collect();
format!( "{{\"processes\":[\n{}\n]}}\n", entries.join( ",\n" ) )
}
}
OutputFormat::Text =>
{
if procs.is_empty()
{
String::new()
}
else
{
let lines : Vec< String > = procs.iter().map( | p |
match opts.verbosity
{
0 => format!( "{} {}", p.pid, p.cwd.display() ),
_ => format!( "PID: {} CWD: {}", p.pid, p.cwd.display() ),
}
).collect();
format!( "{}\n", lines.join( "\n" ) )
}
}
};
Ok( OutputData::new( content, "text" ) )
}
fn send_kill_signals( procs : &[ ProcessInfo ], force : bool ) -> Result< (), ErrorData >
{
if force
{
let mut failures = Vec::new();
for p in procs
{
if let Err( e ) = send_sigkill( p.pid ) { failures.push( format!( "PID {}: {e}", p.pid ) ); }
}
if !failures.is_empty()
{
return Err( ErrorData::new( ErrorCode::InternalError, format!( "SIGKILL failed: {}", failures.join( ", " ) ) ) );
}
}
else
{
let mut failures = Vec::new();
for p in procs
{
if let Err( e ) = send_sigterm( p.pid ) { failures.push( format!( "PID {}: {e}", p.pid ) ); }
}
if !failures.is_empty()
{
return Err( ErrorData::new( ErrorCode::InternalError, format!( "SIGTERM failed: {}", failures.join( ", " ) ) ) );
}
std::thread::sleep( core::time::Duration::from_secs( 2 ) );
let survivors = find_claude_processes();
let mut kfailures = Vec::new();
for p in &survivors
{
if let Err( e ) = send_sigkill( p.pid ) { kfailures.push( format!( "PID {}: {e}", p.pid ) ); }
}
if !kfailures.is_empty()
{
return Err( ErrorData::new( ErrorCode::InternalError, format!( "SIGKILL failed: {}", kfailures.join( ", " ) ) ) );
}
}
Ok( () )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn processes_kill_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let procs = find_claude_processes();
if procs.is_empty()
{
let content = match opts.format
{
OutputFormat::Json => "{\"killed\":0}\n".to_string(),
OutputFormat::Text =>
{
if opts.verbosity == 0 { "0\n".to_string() } else { "no active processes\n".to_string() }
}
};
return Ok( OutputData::new( content, "text" ) );
}
if is_dry( &cmd )
{
let signal = if is_force( &cmd ) { "SIGKILL" } else { "SIGTERM" };
let pids : Vec< String > = procs.iter().map( | p | p.pid.to_string() ).collect();
let content = match opts.format
{
OutputFormat::Json =>
{
format!( "{{\"dry_run\":true,\"signal\":\"{signal}\",\"pids\":[{}]}}\n", pids.join( "," ) )
}
OutputFormat::Text =>
{
if opts.verbosity == 0
{
format!( "{}\n", pids.join( "\n" ) )
}
else
{
let lines : Vec< String > = procs.iter()
.map( | p | format!( "[dry-run] would send {signal} to PID {}", p.pid ) )
.collect();
format!( "{}\n", lines.join( "\n" ) )
}
}
};
return Ok( OutputData::new( content, "text" ) );
}
let count = procs.len();
send_kill_signals( &procs, is_force( &cmd ) )?;
std::thread::sleep( core::time::Duration::from_millis( 500 ) );
let remaining = find_claude_processes().len();
if remaining > 0
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "killed {count} process(es) but {remaining} could not be terminated" ),
) );
}
let content = match opts.format
{
OutputFormat::Json => format!( "{{\"killed\":{count}}}\n" ),
OutputFormat::Text =>
{
if opts.verbosity == 0
{
format!( "{count}\n" )
}
else
{
format!( "killed {count} process(es)\n" )
}
}
};
Ok( OutputData::new( content, "text" ) )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn settings_show_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let paths = require_claude_paths()?;
let pairs = read_all_settings( &paths.settings_file() ).map_err( | e |
ErrorData::new( ErrorCode::InternalError, format!( "failed to read settings: {e}" ) )
)?;
let content = match opts.format
{
OutputFormat::Json =>
{
let body : String = pairs.iter()
.map( | ( k, v ) |
{
let json_val = match infer_type( v )
{
StoredAs::Bool | StoredAs::Number | StoredAs::Raw => v.clone(),
StoredAs::Str => format!( "\"{}\"", json_escape( v ) ),
};
format!( " \"{}\":{json_val}", json_escape( k ) )
} )
.collect::< Vec< _ > >()
.join( ",\n" );
if body.is_empty()
{
"{}\n".to_string()
}
else
{
format!( "{{\n{body}\n}}\n" )
}
}
OutputFormat::Text =>
{
let lines : Vec< String > = pairs.iter().map( | ( k, v ) |
match opts.verbosity
{
0 => format!( "{k}={v}" ),
_ => format!( "{k}: {v}" ),
}
).collect();
if lines.is_empty() { String::new() } else { format!( "{}\n", lines.join( "\n" ) ) }
}
};
Ok( OutputData::new( content, "text" ) )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn settings_get_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let key = require_nonempty_string_arg( &cmd, "key" )?;
let opts = OutputOptions::from_cmd( &cmd )?;
let paths = require_claude_paths()?;
let value = get_setting( &paths.settings_file(), &key )
.map_err( | e |
ErrorData::new( ErrorCode::InternalError, format!( "failed to read settings: {e}" ) )
)?
.ok_or_else( || ErrorData::new(
ErrorCode::InternalError,
format!( "key '{key}' not found in settings" ),
) )?;
let content = match ( opts.format, opts.verbosity )
{
( OutputFormat::Json, _ ) =>
{
let k = json_escape( &key );
let json_val = match infer_type( &value )
{
StoredAs::Bool | StoredAs::Number | StoredAs::Raw => value.clone(),
StoredAs::Str => format!( "\"{}\"", json_escape( &value ) ),
};
format!( "{{\"key\":\"{k}\",\"value\":{json_val}}}\n" )
}
( OutputFormat::Text, 0 ) => format!( "{value}\n" ),
( OutputFormat::Text, _ ) => format!( "{key}: {value}\n" ),
};
Ok( OutputData::new( content, "text" ) )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn settings_set_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let key = require_nonempty_string_arg( &cmd, "key" )?;
let value = require_nonempty_string_arg( &cmd, "value" )?;
let stored_as = infer_type( &value );
if is_dry( &cmd )
{
let type_label = match stored_as
{
StoredAs::Bool => "bool",
StoredAs::Number => "number",
StoredAs::Str => "string",
StoredAs::Raw => "object",
};
return Ok( OutputData::new(
format!( "[dry-run] would set {key} = {value} ({type_label})\n" ),
"text",
) );
}
let paths = require_claude_paths()?;
let settings_file = paths.settings_file();
if let Some( parent ) = settings_file.parent()
{
std::fs::create_dir_all( parent ).map_err( | e |
ErrorData::new( ErrorCode::InternalError, format!( "failed to create settings directory: {e}" ) )
)?;
}
set_setting( &settings_file, &key, &value ).map_err( | e |
ErrorData::new( ErrorCode::InternalError, format!( "failed to write settings: {e}" ) )
)?;
Ok( OutputData::new( format!( "set {key} = {value}\n" ), "text" ) )
}
#[ allow(
clippy::needless_pass_by_value,
clippy::missing_inline_in_public_items,
clippy::too_many_lines,
) ]
pub fn config_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
use claude_version_core::config_catalog::catalog;
use claude_version_core::config_resolve::{ resolve, resolve_all };
use claude_version_core::settings_io::remove_setting;
let key_opt = match cmd.arguments.get( "key" )
{
Some( Value::String( s ) ) if !s.is_empty() => Some( s.clone() ),
_ => None,
};
let value_opt = match cmd.arguments.get( "value" )
{
Some( Value::String( s ) ) if !s.is_empty() => Some( s.clone() ),
_ => None,
};
let is_unset = matches!( cmd.arguments.get( "unset" ), Some( Value::Boolean( true ) ) );
let scope_str = match cmd.arguments.get( "scope" )
{
Some( Value::String( s ) ) => s.clone(),
_ => "user".to_string(),
};
let opts = OutputOptions::from_cmd( &cmd )?;
if value_opt.is_some() && key_opt.is_none()
{
return Err( ErrorData::new( ErrorCode::ArgumentMissing,
"key:: is required when value:: is provided".to_string() ) );
}
if is_unset && key_opt.is_none()
{
return Err( ErrorData::new( ErrorCode::ArgumentMissing,
"key:: is required when unset::1 is provided".to_string() ) );
}
if value_opt.is_some() && is_unset
{
return Err( ErrorData::new( ErrorCode::ArgumentMissing,
"value:: and unset::1 are mutually exclusive".to_string() ) );
}
match scope_str.as_str()
{
"user" | "project" => {}
other => return Err( ErrorData::new( ErrorCode::ArgumentTypeMismatch,
format!( "unknown scope '{other}': expected user or project" ) ) ),
}
match ( key_opt.as_deref(), value_opt.as_deref(), is_unset )
{
( None, None, false ) =>
{
let ( home_dir, cwd ) = config_resolve_context()?;
let all = resolve_all( &home_dir, &cwd, catalog() );
let content = render_config_show_all( &all, &opts );
Ok( OutputData::new( content, "text" ) )
}
( Some( key ), None, false ) =>
{
let ( home_dir, cwd ) = config_resolve_context()?;
let resolved = resolve( key, &home_dir, &cwd, catalog() );
let content = render_config_get( key, &resolved, &opts );
Ok( OutputData::new( content, "text" ) )
}
( Some( key ), Some( value ), false ) =>
{
let stored_as = infer_type( value );
if is_dry( &cmd )
{
let type_label = match stored_as
{
StoredAs::Bool => "bool",
StoredAs::Number => "number",
StoredAs::Str => "string",
StoredAs::Raw => "object",
};
return Ok( OutputData::new(
format!( "[dry-run] would set {key} = {value} ({type_label})\n" ),
"text",
) );
}
let target = config_scope_target( &scope_str )?;
if let Some( parent ) = target.parent()
{
std::fs::create_dir_all( parent ).map_err( | e |
ErrorData::new( ErrorCode::InternalError,
format!( "failed to create settings directory: {e}" ) )
)?;
}
set_setting( &target, key, value ).map_err( | e |
ErrorData::new( ErrorCode::InternalError, format!( "failed to write settings: {e}" ) )
)?;
Ok( OutputData::new( format!( "set {key} = {value}\n" ), "text" ) )
}
( Some( key ), None, true ) =>
{
if is_dry( &cmd )
{
return Ok( OutputData::new(
format!( "[dry-run] would unset {key}\n" ),
"text",
) );
}
let target = config_scope_target( &scope_str )?;
remove_setting( &target, key ).map_err( | e |
ErrorData::new( ErrorCode::InternalError, format!( "failed to unset setting: {e}" ) )
)?;
Ok( OutputData::new( format!( "unset {key}\n" ), "text" ) )
}
_ => unreachable!( "all invalid combinations rejected above" ),
}
}
fn config_resolve_context() -> Result< ( std::path::PathBuf, std::path::PathBuf ), ErrorData >
{
let paths = require_claude_paths()?;
let home_dir = paths.base().parent()
.expect( "ClaudePaths base always has HOME as parent" )
.to_path_buf();
let cwd = std::env::current_dir().unwrap_or_else( | _ | home_dir.clone() );
Ok( ( home_dir, cwd ) )
}
fn config_scope_target( scope : &str ) -> Result< std::path::PathBuf, ErrorData >
{
match scope
{
"user" =>
{
let paths = require_claude_paths()?;
Ok( paths.settings_file() )
}
"project" =>
{
let cwd = std::env::current_dir().map_err( | e |
ErrorData::new( ErrorCode::InternalError, format!( "failed to get current directory: {e}" ) )
)?;
Ok( cwd.join( ".claude" ).join( "settings.json" ) )
}
_ => unreachable!( "scope validated before config_scope_target call" ),
}
}
fn render_config_show_all(
all : &[ ( String, claude_version_core::config_resolve::ResolvedValue ) ],
opts : &OutputOptions,
) -> String
{
match opts.format
{
OutputFormat::Json =>
{
let entries : Vec< String > = all.iter().map( | ( k, rv ) |
{
let src = rv.source.to_string();
let val_json = config_value_to_json( rv.value.as_deref() );
format!( " \"{}\": {{\"value\": {val_json}, \"source\": \"{src}\"}}",
json_escape( k ) )
} ).collect();
if entries.is_empty() { "{}\n".to_string() }
else { format!( "{{\n{}\n}}\n", entries.join( ",\n" ) ) }
}
OutputFormat::Text =>
{
let lines : Vec< String > = all.iter().map( | ( k, rv ) |
{
match &rv.value
{
None => format!( "{k}: ({})", rv.source ),
Some( v ) => format!( "{k}: {v} ({})", rv.source ),
}
} ).collect();
if lines.is_empty() { String::new() }
else { format!( "{}\n", lines.join( "\n" ) ) }
}
}
}
fn render_config_get(
key : &str,
rv : &claude_version_core::config_resolve::ResolvedValue,
opts : &OutputOptions,
) -> String
{
match opts.format
{
OutputFormat::Json =>
{
let src = rv.source.to_string();
let val_json = config_value_to_json( rv.value.as_deref() );
format!( "{{\"key\": \"{}\", \"value\": {val_json}, \"source\": \"{src}\"}}\n",
json_escape( key ) )
}
OutputFormat::Text =>
{
match ( opts.verbosity, &rv.value )
{
( 0, None ) => "(absent)\n".to_string(),
( 0, Some( v ) ) => format!( "{v} ({})\n", rv.source ),
( _, None ) => format!( "{key}: (absent)\n" ),
( _, Some( v ) ) => format!( "{key}: {v} ({})\n", rv.source ),
}
}
}
}
fn config_value_to_json( raw : Option< &str > ) -> String
{
match raw
{
None => "null".to_string(),
Some( v ) => match infer_type( v )
{
StoredAs::Bool | StoredAs::Number | StoredAs::Raw => v.to_string(),
StoredAs::Str => format!( "\"{}\"", json_escape( v ) ),
},
}
}
struct ReleaseInfo
{
version : String,
date : String,
summary : String,
body : String,
}
fn parse_json_string_value( json : &str, key : &str ) -> Option< String >
{
let colon_pat = format!( "\"{key}\":" );
let colon_pos = json.find( &colon_pat )? + colon_pat.len();
let rest = &json[ colon_pos.. ];
let quote = rest.find( '"' )?;
let value_start = colon_pos + quote + 1;
let content = &json[ value_start.. ];
let mut out = String::new();
let mut chars = content.chars();
let mut escaped = false;
while let Some( ch ) = chars.next()
{
if escaped
{
match ch
{
'n' => out.push( '\n' ),
'r' => out.push( '\r' ),
't' => out.push( '\t' ),
'b' => out.push( '\x08' ), 'f' => out.push( '\x0C' ), '"' => out.push( '"' ),
'\\' => out.push( '\\' ),
'u' =>
{
let hex : String = chars.by_ref().take( 4 ).collect();
if hex.len() == 4
{
if let Ok( cp ) = u32::from_str_radix( &hex, 16 )
{
if ( 0xD800..=0xDBFF ).contains( &cp )
{
let mut low_hex = String::new();
if chars.next() == Some( '\\' ) && chars.next() == Some( 'u' )
{
low_hex = chars.by_ref().take( 4 ).collect();
}
if low_hex.len() == 4
{
if let Ok( lo ) = u32::from_str_radix( &low_hex, 16 )
{
if ( 0xDC00..=0xDFFF ).contains( &lo )
{
let scalar = 0x1_0000 + ( ( cp - 0xD800 ) << 10 ) + ( lo - 0xDC00 );
if let Some( c ) = char::from_u32( scalar )
{
out.push( c );
}
}
}
}
}
else if let Some( c ) = char::from_u32( cp )
{
out.push( c );
}
}
}
}
other => out.push( other ),
}
escaped = false;
}
else if ch == '\\'
{
escaped = true;
}
else if ch == '"'
{
return Some( out );
}
else
{
out.push( ch );
}
}
None
}
fn extract_releases( json : &str ) -> Vec< ReleaseInfo >
{
let marker_spaced = "\"tag_name\": \"v";
let marker_compact = "\"tag_name\":\"v";
let ( marker, chunks ) : ( &str, Vec< &str > ) = if json.contains( marker_spaced )
{
( marker_spaced, json.split( marker_spaced ).collect() )
}
else
{
( marker_compact, json.split( marker_compact ).collect() )
};
let mut releases = Vec::new();
for chunk in chunks.iter().skip( 1 )
{
let restored = format!( "{marker}{chunk}" );
let version = parse_json_string_value( &restored, "tag_name" )
.map( | v | v.strip_prefix( 'v' ).unwrap_or( &v ).to_string() )
.unwrap_or_default();
let date = parse_json_string_value( &restored, "published_at" )
.map( | d | d.chars().take( 10 ).collect() )
.unwrap_or_default();
let body_raw = parse_json_string_value( &restored, "body" )
.unwrap_or_default();
let summary = body_raw
.lines()
.find( | l | l.starts_with( "- " ) )
.map_or_else( || "(no changelog)".to_string(), | l | l[ 2.. ].trim().to_string() );
releases.push( ReleaseInfo { version, date, summary, body : body_raw } );
}
releases
}
fn cache_is_fresh( path : &std::path::Path ) -> bool
{
std::fs::metadata( path )
.and_then( | m | m.modified() )
.ok()
.and_then( | mtime | std::time::SystemTime::now().duration_since( mtime ).ok() )
.is_some_and( | elapsed | elapsed.as_secs() < CACHE_TTL_SECS )
}
fn fetch_releases_json( base : &std::path::Path ) -> Result< String, ErrorData >
{
let cache_dir = base.join( ".transient" );
let cache_path = cache_dir.join( "version_history_cache.json" );
if cache_is_fresh( &cache_path )
{
if let Ok( cached ) = std::fs::read_to_string( &cache_path )
{
if !cached.is_empty()
{
return Ok( cached );
}
}
}
let output = std::process::Command::new( "curl" )
.args( [ "-fsSL", RELEASES_API_URL ] )
.output()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "curl not found or fetch failed: {e}" ) ) )?;
if !output.status.success()
{
return Err( ErrorData::new( ErrorCode::InternalError, "failed to fetch release history".to_string() ) );
}
let response = String::from_utf8_lossy( &output.stdout ).to_string();
if response.trim().is_empty()
{
return Err( ErrorData::new( ErrorCode::InternalError, "empty response from GitHub API".to_string() ) );
}
let _ = std::fs::create_dir_all( &cache_dir );
let _ = std::fs::write( &cache_path, &response );
Ok( response )
}
#[ allow( clippy::needless_pass_by_value, clippy::missing_inline_in_public_items ) ]
pub fn version_history_routine( cmd : VerifiedCommand, _ctx : ExecutionContext ) -> Result< OutputData, ErrorData >
{
let opts = OutputOptions::from_cmd( &cmd )?;
let count = match cmd.arguments.get( "count" )
{
Some( Value::Integer( n ) ) => usize::try_from( *n ).unwrap_or( 10 ),
_ => 10,
};
let paths = require_claude_paths()?;
let json = fetch_releases_json( paths.base() )?;
let mut releases = extract_releases( &json );
releases.truncate( count );
let content = match ( opts.format, opts.verbosity )
{
( OutputFormat::Json, _ ) =>
{
if releases.is_empty()
{
"[]\n".to_string()
}
else
{
let entries : Vec< String > = releases.iter().map( | r |
{
let v = json_escape( &r.version );
let d = json_escape( &r.date );
let s = json_escape( &r.summary );
format!( " {{\"version\":\"{v}\",\"date\":\"{d}\",\"summary\":\"{s}\"}}" )
} ).collect();
format!( "[\n{}\n]\n", entries.join( ",\n" ) )
}
}
( OutputFormat::Text, 0 ) =>
{
if releases.is_empty()
{
String::new()
}
else
{
let lines : Vec< String > = releases.iter()
.map( | r | format!( "{} {}", r.version, r.date ) )
.collect();
format!( "{}\n", lines.join( "\n" ) )
}
}
( OutputFormat::Text, 1 ) =>
{
if releases.is_empty()
{
String::new()
}
else
{
let lines : Vec< String > = releases.iter()
.map( | r | format!( "{} {} {}", r.version, r.date, r.summary ) )
.collect();
format!( "{}\n", lines.join( "\n" ) )
}
}
( OutputFormat::Text, _ ) =>
{
if releases.is_empty()
{
String::new()
}
else
{
let blocks : Vec< String > = releases.iter()
.map( | r |
{
let header = format!( "## {} ({})", r.version, r.date );
if r.body.is_empty()
{
header
}
else
{
format!( "{header}\n\n{}", r.body )
}
} )
.collect();
format!( "{}\n", blocks.join( "\n\n" ) )
}
}
};
Ok( OutputData::new( content, "text" ) )
}