use std::fs;
use std::io::{self, BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use crate::commands::{self, InstallOutcome};
use crate::utils::{get_claude_config_dir, get_home_dir};
pub const BINARY_NAME: &str = if cfg!(windows) {
"claude-code-status-line.exe"
} else {
"claude-code-status-line"
};
const DEFAULT_REFRESH_INTERVAL: u64 = 60;
#[derive(Debug, PartialEq)]
pub enum BinaryOutcome {
AlreadyInPlace,
Installed,
UpToDate,
Updated,
}
pub fn install_binary(source: &Path, target: &Path) -> io::Result<BinaryOutcome> {
if let (Ok(src), Ok(dst)) = (source.canonicalize(), target.canonicalize()) {
if src == dst {
return Ok(BinaryOutcome::AlreadyInPlace);
}
}
let new_bytes = fs::read(source)?;
let existed = match fs::read(target) {
Ok(existing) if existing == new_bytes => return Ok(BinaryOutcome::UpToDate),
Ok(_) => true,
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
Err(err) => return Err(err),
};
let dir = target
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "binary path has no parent"))?;
fs::create_dir_all(dir)?;
let tmp = sibling(target, ".new");
fs::write(&tmp, &new_bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))?;
}
if cfg!(windows) && existed {
let old = sibling(target, ".old");
let _ = fs::remove_file(&old);
fs::rename(target, &old)?;
fs::rename(&tmp, target)?;
let _ = fs::remove_file(&old);
} else {
fs::rename(&tmp, target)?;
}
Ok(if existed {
BinaryOutcome::Updated
} else {
BinaryOutcome::Installed
})
}
fn sibling(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(suffix);
path.with_file_name(name)
}
pub fn statusline_command() -> String {
format!("~/.claude/statusline/bin/{}", BINARY_NAME)
}
#[derive(Debug, PartialEq)]
pub struct StatusLinePlan {
pub previous: Option<Value>,
pub new: Value,
pub replaces_other: bool,
}
fn is_ours(status_line: &Value) -> bool {
status_line
.get("command")
.and_then(Value::as_str)
.is_some_and(|cmd| cmd.contains("claude-code-status-line"))
}
pub fn plan_statusline(settings: &Map<String, Value>, command: &str) -> Option<StatusLinePlan> {
let previous = settings.get("statusLine").cloned();
let new = match &previous {
Some(existing @ Value::Object(fields)) if is_ours(existing) => {
let mut updated = fields.clone();
updated.insert("type".into(), json!("command"));
updated.insert("command".into(), json!(command));
Value::Object(updated)
}
_ => json!({
"type": "command",
"command": command,
"padding": 0,
"refreshInterval": DEFAULT_REFRESH_INTERVAL,
}),
};
if previous.as_ref() == Some(&new) {
return None;
}
let replaces_other = previous.as_ref().is_some_and(|p| !is_ours(p));
Some(StatusLinePlan {
previous,
new,
replaces_other,
})
}
#[derive(Debug, PartialEq)]
pub enum SettingsOutcome {
UpToDate,
Updated { backup: Option<PathBuf> },
Skipped,
}
pub fn update_claude_settings(
path: &Path,
command: &str,
confirm: impl FnOnce(&StatusLinePlan) -> bool,
) -> io::Result<SettingsOutcome> {
let original = match fs::read_to_string(path) {
Ok(content) => Some(content),
Err(err) if err.kind() == io::ErrorKind::NotFound => None,
Err(err) => return Err(err),
};
let mut settings = match original.as_deref().map(str::trim) {
None | Some("") => Map::new(),
Some(content) => match serde_json::from_str::<Value>(content) {
Ok(Value::Object(map)) => map,
Ok(_) => return Err(invalid_data(path, "top level is not a JSON object")),
Err(err) => return Err(invalid_data(path, &err.to_string())),
},
};
let Some(plan) = plan_statusline(&settings, command) else {
return Ok(SettingsOutcome::UpToDate);
};
if !confirm(&plan) {
return Ok(SettingsOutcome::Skipped);
}
let dir = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "settings path has no parent")
})?;
fs::create_dir_all(dir)?;
let backup = match original {
Some(content) => {
let name = path.file_name().unwrap_or_default().to_string_lossy();
let backup = commands::next_backup_path(dir, &name);
fs::write(&backup, content)?;
Some(backup)
}
None => None,
};
settings.insert("statusLine".into(), plan.new);
let mut json = serde_json::to_string_pretty(&Value::Object(settings))?;
json.push('\n');
let tmp = sibling(path, ".tmp");
fs::write(&tmp, json)?;
fs::rename(&tmp, path)?;
Ok(SettingsOutcome::Updated { backup })
}
fn invalid_data(path: &Path, reason: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{} is not valid JSON ({}); fix it and run setup again",
path.display(),
reason
),
)
}
pub fn remove_legacy_files(statusline_dir: &Path) -> Vec<PathBuf> {
let legacy = [
BINARY_NAME.to_string(),
if cfg!(windows) {
"claude-code-status-line.bak.exe".to_string()
} else {
"claude-code-status-line.bak".to_string()
},
"quota_cache.json".to_string(),
"quota_cache.json.lock".to_string(),
];
legacy
.iter()
.map(|name| statusline_dir.join(name))
.filter(|path| path.is_file() && fs::remove_file(path).is_ok())
.collect()
}
fn prompt_yes_no(question: &str, default_yes: bool) -> bool {
print!(
"{} {} ",
question,
if default_yes { "[Y/n]" } else { "[y/N]" }
);
let _ = io::stdout().flush();
let mut answer = String::new();
if io::stdin().lock().read_line(&mut answer).is_err() {
return false;
}
match answer.trim().to_ascii_lowercase().as_str() {
"" => default_yes,
"y" | "yes" => true,
_ => false,
}
}
fn describe(value: Option<&Value>) -> String {
value
.map(|v| serde_json::to_string(v).unwrap_or_default())
.unwrap_or_else(|| "(none)".into())
}
pub fn run(yes: bool) -> i32 {
let (Some(home), Some(claude_dir)) = (get_home_dir(), get_claude_config_dir()) else {
eprintln!("error: could not determine home directory");
return 1;
};
let statusline_dir = home.join(".claude").join("statusline");
let target = statusline_dir.join("bin").join(BINARY_NAME);
let source = match std::env::current_exe() {
Ok(path) => path,
Err(err) => {
eprintln!("error: could not locate the running binary: {}", err);
return 1;
}
};
match install_binary(&source, &target) {
Ok(BinaryOutcome::AlreadyInPlace) | Ok(BinaryOutcome::UpToDate) => {
println!("binary up to date ({})", target.display())
}
Ok(BinaryOutcome::Installed) => println!("binary installed to {}", target.display()),
Ok(BinaryOutcome::Updated) => println!("binary updated at {}", target.display()),
Err(err) => {
eprintln!(
"error: failed to install binary to {}: {}",
target.display(),
err
);
return 1;
}
}
crate::config::load_config();
let commands_dir = claude_dir.join("commands");
match commands::install_commands(&commands_dir) {
Ok(results) => {
let changed = results
.iter()
.filter(|(_, outcome)| *outcome != InstallOutcome::UpToDate)
.count();
println!(
"commands {} ({})",
if changed == 0 {
"up to date"
} else {
"installed"
},
commands_dir.display()
);
for (name, outcome) in &results {
if let InstallOutcome::Updated { backup } = outcome {
println!(
" {} changed; previous saved to {}",
name,
backup.display()
);
}
}
}
Err(err) => {
eprintln!(
"error: failed to install commands into {}: {}",
commands_dir.display(),
err
);
return 1;
}
}
let settings_path = claude_dir.join("settings.json");
let interactive = io::stdin().is_terminal();
let mut needed_confirmation = false;
let outcome = update_claude_settings(&settings_path, &statusline_command(), |plan| {
if yes {
return true;
}
println!("\nClaude Code settings: {}", settings_path.display());
println!(" current statusLine: {}", describe(plan.previous.as_ref()));
println!(" new statusLine: {}", describe(Some(&plan.new)));
if !interactive {
needed_confirmation = true;
return false;
}
if plan.replaces_other {
prompt_yes_no("Replace your current status line?", false)
} else {
prompt_yes_no("Update settings.json?", true)
}
});
let exit_code = match outcome {
Ok(SettingsOutcome::UpToDate) => {
println!(
"settings statusLine up to date ({})",
settings_path.display()
);
0
}
Ok(SettingsOutcome::Updated { backup }) => {
println!(
"settings statusLine updated ({})",
settings_path.display()
);
if let Some(backup) = backup {
println!(" previous saved to {}", backup.display());
}
0
}
Ok(SettingsOutcome::Skipped) if needed_confirmation => {
eprintln!("settings not changed: run setup --yes to apply without a prompt");
return 1;
}
Ok(SettingsOutcome::Skipped) => {
println!("settings not changed");
return 0;
}
Err(err) => {
eprintln!("error: {}", err);
return 1;
}
};
for path in remove_legacy_files(&statusline_dir) {
println!("cleanup removed {}", path.display());
}
println!("\nDone. Restart Claude Code to load the status line and the /*-statusline commands.");
println!("Run /customize-statusline in Claude Code to change what it shows.");
exit_code
}
#[cfg(test)]
mod tests {
use super::*;
fn read_json(path: &Path) -> Value {
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
}
#[test]
fn test_install_binary_fresh_then_up_to_date_then_updated() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("source");
let target = dir.path().join("bin").join(BINARY_NAME);
fs::write(&source, b"v1").unwrap();
assert_eq!(
install_binary(&source, &target).unwrap(),
BinaryOutcome::Installed
);
assert_eq!(
install_binary(&source, &target).unwrap(),
BinaryOutcome::UpToDate
);
fs::write(&source, b"v2").unwrap();
assert_eq!(
install_binary(&source, &target).unwrap(),
BinaryOutcome::Updated
);
assert_eq!(fs::read(&target).unwrap(), b"v2");
assert!(!sibling(&target, ".new").exists());
assert!(!sibling(&target, ".old").exists());
}
#[test]
fn test_install_binary_from_installed_copy() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join(BINARY_NAME);
fs::write(&target, b"v1").unwrap();
assert_eq!(
install_binary(&target, &target).unwrap(),
BinaryOutcome::AlreadyInPlace
);
}
#[cfg(unix)]
#[test]
fn test_install_binary_is_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("source");
let target = dir.path().join(BINARY_NAME);
fs::write(&source, b"v1").unwrap();
install_binary(&source, &target).unwrap();
let mode = fs::metadata(&target).unwrap().permissions().mode();
assert_eq!(mode & 0o111, 0o111);
}
#[test]
fn test_statusline_command_is_shell_neutral() {
let command = statusline_command();
assert!(command.starts_with("~/.claude/statusline/bin/claude-code-status-line"));
assert!(
!command.contains([' ', '\\', '"', '\'']),
"needs quoting in some shell: {}",
command
);
assert!(is_ours(&json!({ "command": command })));
}
#[test]
fn test_settings_created_when_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let outcome = update_claude_settings(&path, "\"/bin/sl\"", |_| true).unwrap();
assert_eq!(outcome, SettingsOutcome::Updated { backup: None });
assert_eq!(
read_json(&path),
json!({"statusLine": {
"type": "command",
"command": "\"/bin/sl\"",
"padding": 0,
"refreshInterval": 60
}})
);
}
#[test]
fn test_settings_merge_preserves_other_keys_and_order() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(
&path,
r#"{"zeta": 1, "model": "opus", "alpha": {"x": true}}"#,
)
.unwrap();
let outcome = update_claude_settings(&path, "sl", |_| true).unwrap();
let backup = dir.path().join("settings.json.bak");
assert_eq!(
outcome,
SettingsOutcome::Updated {
backup: Some(backup.clone())
}
);
let written = fs::read_to_string(&path).unwrap();
let keys: Vec<_> = ["\"zeta\"", "\"model\"", "\"alpha\"", "\"statusLine\""]
.iter()
.map(|k| written.find(k).unwrap())
.collect();
assert!(
keys.windows(2).all(|w| w[0] < w[1]),
"key order changed: {}",
written
);
assert_eq!(
fs::read_to_string(backup).unwrap(),
r#"{"zeta": 1, "model": "opus", "alpha": {"x": true}}"#
);
}
#[test]
fn test_settings_own_entry_keeps_user_options() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(
&path,
r#"{"statusLine": {"type": "command", "command": "~/.claude/statusline/claude-code-status-line", "padding": 2}}"#,
)
.unwrap();
let mut seen = None;
update_claude_settings(&path, "\"/new/claude-code-status-line\"", |plan| {
seen = Some(plan.replaces_other);
true
})
.unwrap();
assert_eq!(seen, Some(false));
assert_eq!(
read_json(&path)["statusLine"],
json!({"type": "command", "command": "\"/new/claude-code-status-line\"", "padding": 2})
);
}
#[test]
fn test_settings_up_to_date_does_not_prompt_or_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let content =
r#"{"statusLine": {"type": "command", "command": "\"/x/claude-code-status-line\""}}"#;
fs::write(&path, content).unwrap();
let outcome = update_claude_settings(&path, "\"/x/claude-code-status-line\"", |_| {
panic!("prompted")
})
.unwrap();
assert_eq!(outcome, SettingsOutcome::UpToDate);
assert_eq!(fs::read_to_string(&path).unwrap(), content);
assert!(!dir.path().join("settings.json.bak").exists());
}
#[test]
fn test_settings_other_statusline_flagged_and_declinable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let content = r#"{"statusLine": {"type": "command", "command": "~/my-status.sh"}}"#;
fs::write(&path, content).unwrap();
let mut seen = None;
let outcome = update_claude_settings(&path, "sl", |plan| {
seen = Some(plan.replaces_other);
false
})
.unwrap();
assert_eq!(seen, Some(true));
assert_eq!(outcome, SettingsOutcome::Skipped);
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_settings_invalid_json_is_left_untouched() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, "{ not json").unwrap();
let err = update_claude_settings(&path, "sl", |_| true).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
}
#[test]
fn test_settings_non_object_root_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, "[1, 2]").unwrap();
let err = update_claude_settings(&path, "sl", |_| true).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn test_remove_legacy_files_only_removes_known_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(BINARY_NAME), b"old").unwrap();
fs::write(dir.path().join("quota_cache.json"), b"{}").unwrap();
fs::write(dir.path().join("settings.json"), b"{}").unwrap();
fs::create_dir(dir.path().join("bin")).unwrap();
let removed = remove_legacy_files(dir.path());
assert_eq!(removed.len(), 2);
assert!(!dir.path().join(BINARY_NAME).exists());
assert!(dir.path().join("settings.json").exists());
assert!(dir.path().join("bin").exists());
}
}