1use std::fs;
8use std::io::{self, BufRead, IsTerminal, Write};
9use std::path::{Path, PathBuf};
10
11use serde_json::{Map, Value, json};
12
13use crate::commands::{self, InstallOutcome};
14use crate::utils::{get_claude_config_dir, get_home_dir};
15
16pub const BINARY_NAME: &str = if cfg!(windows) {
17 "claude-code-status-line.exe"
18} else {
19 "claude-code-status-line"
20};
21
22const DEFAULT_REFRESH_INTERVAL: u64 = 60;
24
25#[derive(Debug, PartialEq)]
26pub enum BinaryOutcome {
27 AlreadyInPlace,
29 Installed,
30 UpToDate,
31 Updated,
32}
33
34pub fn install_binary(source: &Path, target: &Path) -> io::Result<BinaryOutcome> {
37 if let (Ok(src), Ok(dst)) = (source.canonicalize(), target.canonicalize()) {
38 if src == dst {
39 return Ok(BinaryOutcome::AlreadyInPlace);
40 }
41 }
42
43 let new_bytes = fs::read(source)?;
44 let existed = match fs::read(target) {
45 Ok(existing) if existing == new_bytes => return Ok(BinaryOutcome::UpToDate),
46 Ok(_) => true,
47 Err(err) if err.kind() == io::ErrorKind::NotFound => false,
48 Err(err) => return Err(err),
49 };
50
51 let dir = target
52 .parent()
53 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "binary path has no parent"))?;
54 fs::create_dir_all(dir)?;
55
56 let tmp = sibling(target, ".new");
57 fs::write(&tmp, &new_bytes)?;
58 #[cfg(unix)]
59 {
60 use std::os::unix::fs::PermissionsExt;
61 fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))?;
62 }
63
64 if cfg!(windows) && existed {
67 let old = sibling(target, ".old");
68 let _ = fs::remove_file(&old);
69 fs::rename(target, &old)?;
70 fs::rename(&tmp, target)?;
71 let _ = fs::remove_file(&old);
72 } else {
73 fs::rename(&tmp, target)?;
74 }
75
76 Ok(if existed {
77 BinaryOutcome::Updated
78 } else {
79 BinaryOutcome::Installed
80 })
81}
82
83fn sibling(path: &Path, suffix: &str) -> PathBuf {
84 let mut name = path.file_name().unwrap_or_default().to_os_string();
85 name.push(suffix);
86 path.with_file_name(name)
87}
88
89pub fn statusline_command() -> String {
96 format!("~/.claude/statusline/bin/{}", BINARY_NAME)
97}
98
99#[derive(Debug, PartialEq)]
101pub struct StatusLinePlan {
102 pub previous: Option<Value>,
103 pub new: Value,
104 pub replaces_other: bool,
106}
107
108fn is_ours(status_line: &Value) -> bool {
109 status_line
110 .get("command")
111 .and_then(Value::as_str)
112 .is_some_and(|cmd| cmd.contains("claude-code-status-line"))
113}
114
115pub fn plan_statusline(settings: &Map<String, Value>, command: &str) -> Option<StatusLinePlan> {
119 let previous = settings.get("statusLine").cloned();
120
121 let new = match &previous {
122 Some(existing @ Value::Object(fields)) if is_ours(existing) => {
123 let mut updated = fields.clone();
124 updated.insert("type".into(), json!("command"));
125 updated.insert("command".into(), json!(command));
126 Value::Object(updated)
127 }
128 _ => json!({
129 "type": "command",
130 "command": command,
131 "padding": 0,
132 "refreshInterval": DEFAULT_REFRESH_INTERVAL,
133 }),
134 };
135
136 if previous.as_ref() == Some(&new) {
137 return None;
138 }
139
140 let replaces_other = previous.as_ref().is_some_and(|p| !is_ours(p));
141 Some(StatusLinePlan {
142 previous,
143 new,
144 replaces_other,
145 })
146}
147
148#[derive(Debug, PartialEq)]
149pub enum SettingsOutcome {
150 UpToDate,
151 Updated { backup: Option<PathBuf> },
152 Skipped,
153}
154
155pub fn update_claude_settings(
158 path: &Path,
159 command: &str,
160 confirm: impl FnOnce(&StatusLinePlan) -> bool,
161) -> io::Result<SettingsOutcome> {
162 let original = match fs::read_to_string(path) {
163 Ok(content) => Some(content),
164 Err(err) if err.kind() == io::ErrorKind::NotFound => None,
165 Err(err) => return Err(err),
166 };
167
168 let mut settings = match original.as_deref().map(str::trim) {
169 None | Some("") => Map::new(),
170 Some(content) => match serde_json::from_str::<Value>(content) {
171 Ok(Value::Object(map)) => map,
172 Ok(_) => return Err(invalid_data(path, "top level is not a JSON object")),
173 Err(err) => return Err(invalid_data(path, &err.to_string())),
174 },
175 };
176
177 let Some(plan) = plan_statusline(&settings, command) else {
178 return Ok(SettingsOutcome::UpToDate);
179 };
180 if !confirm(&plan) {
181 return Ok(SettingsOutcome::Skipped);
182 }
183
184 let dir = path.parent().ok_or_else(|| {
185 io::Error::new(io::ErrorKind::InvalidInput, "settings path has no parent")
186 })?;
187 fs::create_dir_all(dir)?;
188
189 let backup = match original {
190 Some(content) => {
191 let name = path.file_name().unwrap_or_default().to_string_lossy();
192 let backup = commands::next_backup_path(dir, &name);
193 fs::write(&backup, content)?;
194 Some(backup)
195 }
196 None => None,
197 };
198
199 settings.insert("statusLine".into(), plan.new);
200 let mut json = serde_json::to_string_pretty(&Value::Object(settings))?;
201 json.push('\n');
202
203 let tmp = sibling(path, ".tmp");
204 fs::write(&tmp, json)?;
205 fs::rename(&tmp, path)?;
206
207 Ok(SettingsOutcome::Updated { backup })
208}
209
210fn invalid_data(path: &Path, reason: &str) -> io::Error {
211 io::Error::new(
212 io::ErrorKind::InvalidData,
213 format!(
214 "{} is not valid JSON ({}); fix it and run setup again",
215 path.display(),
216 reason
217 ),
218 )
219}
220
221pub fn remove_legacy_files(statusline_dir: &Path) -> Vec<PathBuf> {
224 let legacy = [
225 BINARY_NAME.to_string(),
226 if cfg!(windows) {
227 "claude-code-status-line.bak.exe".to_string()
228 } else {
229 "claude-code-status-line.bak".to_string()
230 },
231 "quota_cache.json".to_string(),
232 "quota_cache.json.lock".to_string(),
233 ];
234 legacy
235 .iter()
236 .map(|name| statusline_dir.join(name))
237 .filter(|path| path.is_file() && fs::remove_file(path).is_ok())
238 .collect()
239}
240
241fn prompt_yes_no(question: &str, default_yes: bool) -> bool {
242 print!(
243 "{} {} ",
244 question,
245 if default_yes { "[Y/n]" } else { "[y/N]" }
246 );
247 let _ = io::stdout().flush();
248 let mut answer = String::new();
249 if io::stdin().lock().read_line(&mut answer).is_err() {
250 return false;
251 }
252 match answer.trim().to_ascii_lowercase().as_str() {
253 "" => default_yes,
254 "y" | "yes" => true,
255 _ => false,
256 }
257}
258
259fn describe(value: Option<&Value>) -> String {
260 value
261 .map(|v| serde_json::to_string(v).unwrap_or_default())
262 .unwrap_or_else(|| "(none)".into())
263}
264
265pub fn run(yes: bool) -> i32 {
267 let (Some(home), Some(claude_dir)) = (get_home_dir(), get_claude_config_dir()) else {
268 eprintln!("error: could not determine home directory");
269 return 1;
270 };
271 let statusline_dir = home.join(".claude").join("statusline");
273 let target = statusline_dir.join("bin").join(BINARY_NAME);
274
275 let source = match std::env::current_exe() {
276 Ok(path) => path,
277 Err(err) => {
278 eprintln!("error: could not locate the running binary: {}", err);
279 return 1;
280 }
281 };
282 match install_binary(&source, &target) {
283 Ok(BinaryOutcome::AlreadyInPlace) | Ok(BinaryOutcome::UpToDate) => {
284 println!("binary up to date ({})", target.display())
285 }
286 Ok(BinaryOutcome::Installed) => println!("binary installed to {}", target.display()),
287 Ok(BinaryOutcome::Updated) => println!("binary updated at {}", target.display()),
288 Err(err) => {
289 eprintln!(
290 "error: failed to install binary to {}: {}",
291 target.display(),
292 err
293 );
294 return 1;
295 }
296 }
297
298 crate::config::load_config();
300
301 let commands_dir = claude_dir.join("commands");
302 match commands::install_commands(&commands_dir) {
303 Ok(results) => {
304 let changed = results
305 .iter()
306 .filter(|(_, outcome)| *outcome != InstallOutcome::UpToDate)
307 .count();
308 println!(
309 "commands {} ({})",
310 if changed == 0 {
311 "up to date"
312 } else {
313 "installed"
314 },
315 commands_dir.display()
316 );
317 for (name, outcome) in &results {
318 if let InstallOutcome::Updated { backup } = outcome {
319 println!(
320 " {} changed; previous saved to {}",
321 name,
322 backup.display()
323 );
324 }
325 }
326 }
327 Err(err) => {
328 eprintln!(
329 "error: failed to install commands into {}: {}",
330 commands_dir.display(),
331 err
332 );
333 return 1;
334 }
335 }
336
337 let settings_path = claude_dir.join("settings.json");
338 let interactive = io::stdin().is_terminal();
339 let mut needed_confirmation = false;
340 let outcome = update_claude_settings(&settings_path, &statusline_command(), |plan| {
341 if yes {
342 return true;
343 }
344 println!("\nClaude Code settings: {}", settings_path.display());
345 println!(" current statusLine: {}", describe(plan.previous.as_ref()));
346 println!(" new statusLine: {}", describe(Some(&plan.new)));
347 if !interactive {
348 needed_confirmation = true;
349 return false;
350 }
351 if plan.replaces_other {
352 prompt_yes_no("Replace your current status line?", false)
353 } else {
354 prompt_yes_no("Update settings.json?", true)
355 }
356 });
357
358 let exit_code = match outcome {
359 Ok(SettingsOutcome::UpToDate) => {
360 println!(
361 "settings statusLine up to date ({})",
362 settings_path.display()
363 );
364 0
365 }
366 Ok(SettingsOutcome::Updated { backup }) => {
367 println!(
368 "settings statusLine updated ({})",
369 settings_path.display()
370 );
371 if let Some(backup) = backup {
372 println!(" previous saved to {}", backup.display());
373 }
374 0
375 }
376 Ok(SettingsOutcome::Skipped) if needed_confirmation => {
377 eprintln!("settings not changed: run setup --yes to apply without a prompt");
378 return 1;
379 }
380 Ok(SettingsOutcome::Skipped) => {
381 println!("settings not changed");
382 return 0;
383 }
384 Err(err) => {
385 eprintln!("error: {}", err);
386 return 1;
387 }
388 };
389
390 for path in remove_legacy_files(&statusline_dir) {
391 println!("cleanup removed {}", path.display());
392 }
393
394 println!("\nDone. Restart Claude Code to load the status line and the /*-statusline commands.");
395 println!("Run /customize-statusline in Claude Code to change what it shows.");
396 exit_code
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 fn read_json(path: &Path) -> Value {
404 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
405 }
406
407 #[test]
408 fn test_install_binary_fresh_then_up_to_date_then_updated() {
409 let dir = tempfile::tempdir().unwrap();
410 let source = dir.path().join("source");
411 let target = dir.path().join("bin").join(BINARY_NAME);
412 fs::write(&source, b"v1").unwrap();
413
414 assert_eq!(
415 install_binary(&source, &target).unwrap(),
416 BinaryOutcome::Installed
417 );
418 assert_eq!(
419 install_binary(&source, &target).unwrap(),
420 BinaryOutcome::UpToDate
421 );
422
423 fs::write(&source, b"v2").unwrap();
424 assert_eq!(
425 install_binary(&source, &target).unwrap(),
426 BinaryOutcome::Updated
427 );
428 assert_eq!(fs::read(&target).unwrap(), b"v2");
429 assert!(!sibling(&target, ".new").exists());
430 assert!(!sibling(&target, ".old").exists());
431 }
432
433 #[test]
434 fn test_install_binary_from_installed_copy() {
435 let dir = tempfile::tempdir().unwrap();
436 let target = dir.path().join(BINARY_NAME);
437 fs::write(&target, b"v1").unwrap();
438
439 assert_eq!(
440 install_binary(&target, &target).unwrap(),
441 BinaryOutcome::AlreadyInPlace
442 );
443 }
444
445 #[cfg(unix)]
446 #[test]
447 fn test_install_binary_is_executable() {
448 use std::os::unix::fs::PermissionsExt;
449 let dir = tempfile::tempdir().unwrap();
450 let source = dir.path().join("source");
451 let target = dir.path().join(BINARY_NAME);
452 fs::write(&source, b"v1").unwrap();
453
454 install_binary(&source, &target).unwrap();
455 let mode = fs::metadata(&target).unwrap().permissions().mode();
456 assert_eq!(mode & 0o111, 0o111);
457 }
458
459 #[test]
460 fn test_statusline_command_is_shell_neutral() {
461 let command = statusline_command();
462 assert!(command.starts_with("~/.claude/statusline/bin/claude-code-status-line"));
463 assert!(
464 !command.contains([' ', '\\', '"', '\'']),
465 "needs quoting in some shell: {}",
466 command
467 );
468 assert!(is_ours(&json!({ "command": command })));
469 }
470
471 #[test]
472 fn test_settings_created_when_missing() {
473 let dir = tempfile::tempdir().unwrap();
474 let path = dir.path().join("settings.json");
475
476 let outcome = update_claude_settings(&path, "\"/bin/sl\"", |_| true).unwrap();
477
478 assert_eq!(outcome, SettingsOutcome::Updated { backup: None });
479 assert_eq!(
480 read_json(&path),
481 json!({"statusLine": {
482 "type": "command",
483 "command": "\"/bin/sl\"",
484 "padding": 0,
485 "refreshInterval": 60
486 }})
487 );
488 }
489
490 #[test]
491 fn test_settings_merge_preserves_other_keys_and_order() {
492 let dir = tempfile::tempdir().unwrap();
493 let path = dir.path().join("settings.json");
494 fs::write(
495 &path,
496 r#"{"zeta": 1, "model": "opus", "alpha": {"x": true}}"#,
497 )
498 .unwrap();
499
500 let outcome = update_claude_settings(&path, "sl", |_| true).unwrap();
501
502 let backup = dir.path().join("settings.json.bak");
503 assert_eq!(
504 outcome,
505 SettingsOutcome::Updated {
506 backup: Some(backup.clone())
507 }
508 );
509 let written = fs::read_to_string(&path).unwrap();
510 let keys: Vec<_> = ["\"zeta\"", "\"model\"", "\"alpha\"", "\"statusLine\""]
511 .iter()
512 .map(|k| written.find(k).unwrap())
513 .collect();
514 assert!(
515 keys.windows(2).all(|w| w[0] < w[1]),
516 "key order changed: {}",
517 written
518 );
519 assert_eq!(
520 fs::read_to_string(backup).unwrap(),
521 r#"{"zeta": 1, "model": "opus", "alpha": {"x": true}}"#
522 );
523 }
524
525 #[test]
526 fn test_settings_own_entry_keeps_user_options() {
527 let dir = tempfile::tempdir().unwrap();
528 let path = dir.path().join("settings.json");
529 fs::write(
530 &path,
531 r#"{"statusLine": {"type": "command", "command": "~/.claude/statusline/claude-code-status-line", "padding": 2}}"#,
532 )
533 .unwrap();
534
535 let mut seen = None;
536 update_claude_settings(&path, "\"/new/claude-code-status-line\"", |plan| {
537 seen = Some(plan.replaces_other);
538 true
539 })
540 .unwrap();
541
542 assert_eq!(seen, Some(false));
543 assert_eq!(
544 read_json(&path)["statusLine"],
545 json!({"type": "command", "command": "\"/new/claude-code-status-line\"", "padding": 2})
546 );
547 }
548
549 #[test]
550 fn test_settings_up_to_date_does_not_prompt_or_write() {
551 let dir = tempfile::tempdir().unwrap();
552 let path = dir.path().join("settings.json");
553 let content =
554 r#"{"statusLine": {"type": "command", "command": "\"/x/claude-code-status-line\""}}"#;
555 fs::write(&path, content).unwrap();
556
557 let outcome = update_claude_settings(&path, "\"/x/claude-code-status-line\"", |_| {
558 panic!("prompted")
559 })
560 .unwrap();
561
562 assert_eq!(outcome, SettingsOutcome::UpToDate);
563 assert_eq!(fs::read_to_string(&path).unwrap(), content);
564 assert!(!dir.path().join("settings.json.bak").exists());
565 }
566
567 #[test]
568 fn test_settings_other_statusline_flagged_and_declinable() {
569 let dir = tempfile::tempdir().unwrap();
570 let path = dir.path().join("settings.json");
571 let content = r#"{"statusLine": {"type": "command", "command": "~/my-status.sh"}}"#;
572 fs::write(&path, content).unwrap();
573
574 let mut seen = None;
575 let outcome = update_claude_settings(&path, "sl", |plan| {
576 seen = Some(plan.replaces_other);
577 false
578 })
579 .unwrap();
580
581 assert_eq!(seen, Some(true));
582 assert_eq!(outcome, SettingsOutcome::Skipped);
583 assert_eq!(fs::read_to_string(&path).unwrap(), content);
584 }
585
586 #[test]
587 fn test_settings_invalid_json_is_left_untouched() {
588 let dir = tempfile::tempdir().unwrap();
589 let path = dir.path().join("settings.json");
590 fs::write(&path, "{ not json").unwrap();
591
592 let err = update_claude_settings(&path, "sl", |_| true).unwrap_err();
593
594 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
595 assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
596 }
597
598 #[test]
599 fn test_settings_non_object_root_rejected() {
600 let dir = tempfile::tempdir().unwrap();
601 let path = dir.path().join("settings.json");
602 fs::write(&path, "[1, 2]").unwrap();
603
604 let err = update_claude_settings(&path, "sl", |_| true).unwrap_err();
605 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
606 }
607
608 #[test]
609 fn test_remove_legacy_files_only_removes_known_files() {
610 let dir = tempfile::tempdir().unwrap();
611 fs::write(dir.path().join(BINARY_NAME), b"old").unwrap();
612 fs::write(dir.path().join("quota_cache.json"), b"{}").unwrap();
613 fs::write(dir.path().join("settings.json"), b"{}").unwrap();
614 fs::create_dir(dir.path().join("bin")).unwrap();
615
616 let removed = remove_legacy_files(dir.path());
617
618 assert_eq!(removed.len(), 2);
619 assert!(!dir.path().join(BINARY_NAME).exists());
620 assert!(dir.path().join("settings.json").exists());
621 assert!(dir.path().join("bin").exists());
622 }
623}