Skip to main content

claude_code_status_line/
setup.rs

1//! `setup` subcommand: copies the running binary to a stable location, installs
2//! the slash commands and points Claude Code's `statusLine` at the installed binary.
3//!
4//! npm runs the binary from its package cache, so `statusLine.command` must never
5//! point at `current_exe()`; it points at the copy in `~/.claude/statusline/bin/`.
6
7use 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
22/// Default `refreshInterval` for new installs: keeps quota countdowns current while idle.
23const DEFAULT_REFRESH_INTERVAL: u64 = 60;
24
25#[derive(Debug, PartialEq)]
26pub enum BinaryOutcome {
27    /// Setup is running from the installed binary itself (e.g. after `cargo install`).
28    AlreadyInPlace,
29    Installed,
30    UpToDate,
31    Updated,
32}
33
34/// Copy `source` to `target`, replacing an existing binary without a window where
35/// `target` is missing or half-written.
36pub 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    // Windows cannot overwrite an executable that is running (Claude Code may be
65    // rendering the status line right now), but it can rename it out of the way.
66    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
89/// `statusLine.command` for the binary installed in `~/.claude/statusline/bin/`.
90///
91/// Claude Code runs the command through sh, Git Bash or, on Windows without Git Bash,
92/// PowerShell. A `~` path with forward slashes and no spaces runs unchanged in all of
93/// them; a quoted absolute path is only a string literal to PowerShell and spaces in
94/// the home directory would need quoting.
95pub fn statusline_command() -> String {
96    format!("~/.claude/statusline/bin/{}", BINARY_NAME)
97}
98
99/// A pending change to Claude Code's `statusLine` setting.
100#[derive(Debug, PartialEq)]
101pub struct StatusLinePlan {
102    pub previous: Option<Value>,
103    pub new: Value,
104    /// The previous `statusLine` belongs to another tool and will be replaced.
105    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
115/// Work out the `statusLine` value for `command`. Our own entry keeps its extra keys
116/// (padding, refreshInterval, ...); anything else is replaced with the defaults.
117/// Returns `None` when the settings already point at `command`.
118pub 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
155/// Point `statusLine` in Claude Code's `settings.json` at `command`. `confirm` sees the
156/// plan before anything is written; the previous file is kept as a numbered backup.
157pub 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
221/// Remove files left behind by installs ≤ 1.2.x (binary outside `bin/`, quota cache).
222/// Only call once `statusLine` no longer points at the legacy binary.
223pub 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
265/// Entry point for `claude-code-status-line setup [--yes]`. Returns the exit code.
266pub 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    // Must match config.rs, which always reads ~/.claude/statusline.
272    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    // Writes settings.json / colors.json with defaults when they are missing.
299    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}