Skip to main content

cc_switch/
statusline.rs

1//! StatusLine integration module
2//!
3//! Provides functionality to install/uninstall a wrapper script that displays
4//! the current cc-switch alias name in Claude Code's statusLine.
5
6use anyhow::{Context, Result};
7use base64::Engine;
8use std::fs;
9use std::os::unix::fs::PermissionsExt;
10use std::path::PathBuf;
11use std::process::Command;
12
13use crate::config::types::ClaudeSettings;
14
15/// Default statusLine command if none is configured
16const DEFAULT_STATUSLINE_CMD: &str = "bunx -y ccstatusline@latest";
17
18/// Marker comment prefix for storing original command
19const MARKER_PREFIX: &str = "# CC_SWITCH_ORIGINAL_CMD: ";
20
21/// Check if a command is available in PATH
22fn is_command_available(cmd: &str) -> bool {
23    Command::new("which")
24        .arg(cmd)
25        .stdout(std::process::Stdio::null())
26        .stderr(std::process::Stdio::null())
27        .status()
28        .map(|s| s.success())
29        .unwrap_or(false)
30}
31
32/// Detect available package manager for running ccstatusline
33///
34/// Priority:
35/// 1. bun (bunx) - faster
36/// 2. npm (npx) - fallback
37///
38/// Returns the command string to use, or None if neither is available.
39fn detect_statusline_runner() -> Option<&'static str> {
40    if is_command_available("bun") {
41        Some("bunx -y ccstatusline@latest")
42    } else if is_command_available("npx") {
43        Some("npx -y ccstatusline@latest")
44    } else {
45        None
46    }
47}
48
49/// Get the path to the wrapper script
50fn get_wrapper_script_path() -> Result<PathBuf> {
51    let config_file = crate::config::get_config_storage_path()?;
52    let config_dir = config_file
53        .parent()
54        .context("Could not get config directory")?;
55    Ok(config_dir.join("cc_auto_switch_statusline.sh"))
56}
57
58/// Generate the wrapper script content
59fn generate_script(original_cmd: &str) -> String {
60    let encoded = base64::engine::general_purpose::STANDARD.encode(original_cmd);
61    format!(
62        r#"#!/usr/bin/env bash
63{marker}{encoded}
64alias_name=""
65# Priority: environment variable (per-session) > file (global fallback)
66if [ -n "$CC_SWITCH_CURRENT_ALIAS" ]; then
67  alias_name="$CC_SWITCH_CURRENT_ALIAS"
68elif [ -f "$HOME/.claude/cc_auto_switch_current_alias" ]; then
69  alias_name=$(cat "$HOME/.claude/cc_auto_switch_current_alias" 2>/dev/null)
70fi
71if [ -n "$alias_name" ]; then
72  printf '[%s] ' "$alias_name"
73fi
74{original_cmd}
75"#,
76        marker = MARKER_PREFIX,
77        encoded = encoded,
78        original_cmd = original_cmd,
79    )
80}
81
82/// Extract the original command from a wrapper script
83fn extract_original_cmd(script_content: &str) -> Option<String> {
84    for line in script_content.lines() {
85        if let Some(encoded) = line.strip_prefix(MARKER_PREFIX)
86            && let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded)
87        {
88            return String::from_utf8(decoded).ok();
89        }
90    }
91    None
92}
93
94/// Install the statusLine wrapper script
95///
96/// Reads the current statusLine command from settings.json, generates a wrapper
97/// script that prepends the current alias name, and updates settings.json to
98/// use the wrapper script.
99///
100/// If no statusLine is configured, it will detect the available package manager
101/// (bun or npm) and use the appropriate command.
102pub fn install(custom_dir: Option<&str>) -> Result<()> {
103    let mut settings = ClaudeSettings::load(custom_dir)?;
104
105    // Get current statusLine command, or detect available runner if none configured
106    let has_existing = settings
107        .other
108        .get("statusLine")
109        .and_then(|v| v.get("command"))
110        .is_some();
111
112    let original_cmd = if has_existing {
113        settings
114            .other
115            .get("statusLine")
116            .and_then(|v| v.get("command"))
117            .and_then(|v| v.as_str())
118            .unwrap_or(DEFAULT_STATUSLINE_CMD)
119            .to_string()
120    } else {
121        // No existing statusLine, detect available package manager
122        match detect_statusline_runner() {
123            Some(cmd) => {
124                println!(
125                    "Detected package manager: {}",
126                    if cmd.contains("bun") { "bun" } else { "npm" }
127                );
128                cmd.to_string()
129            }
130            None => {
131                anyhow::bail!(
132                    "No package manager found (bun or npm required for ccstatusline).\n\
133                     Please install bun or npm, then run: cc-switch statusline install"
134                );
135            }
136        }
137    };
138
139    // Check if already installed with same command
140    let wrapper_path = get_wrapper_script_path()?;
141    if wrapper_path.exists()
142        && let Ok(existing) = fs::read_to_string(&wrapper_path)
143        && let Some(existing_cmd) = extract_original_cmd(&existing)
144        && existing_cmd == original_cmd
145    {
146        println!("StatusLine wrapper already installed with the same command.");
147        return Ok(());
148    }
149
150    // Generate wrapper script
151    let script = generate_script(&original_cmd);
152
153    // Create directory if needed
154    if let Some(parent) = wrapper_path.parent() {
155        fs::create_dir_all(parent)
156            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
157    }
158
159    // Write script
160    fs::write(&wrapper_path, &script).with_context(|| {
161        format!(
162            "Failed to write wrapper script to {}",
163            wrapper_path.display()
164        )
165    })?;
166
167    // Make executable
168    #[cfg(unix)]
169    {
170        let mut perms = fs::metadata(&wrapper_path)?.permissions();
171        perms.set_mode(0o755);
172        fs::set_permissions(&wrapper_path, perms)?;
173    }
174
175    // Update settings.json
176    let wrapper_cmd = format!("bash {}", wrapper_path.display());
177
178    // Build new statusLine object
179    let mut status_line = serde_json::Map::new();
180    status_line.insert(
181        "type".to_string(),
182        serde_json::Value::String("command".to_string()),
183    );
184    status_line.insert(
185        "command".to_string(),
186        serde_json::Value::String(wrapper_cmd.clone()),
187    );
188
189    // Preserve padding if it existed
190    if let Some(existing) = settings.other.get("statusLine") {
191        if let Some(padding) = existing.get("padding") {
192            status_line.insert("padding".to_string(), padding.clone());
193        }
194    } else {
195        status_line.insert(
196            "padding".to_string(),
197            serde_json::Value::Number(serde_json::Number::from(0)),
198        );
199    }
200
201    settings.other.insert(
202        "statusLine".to_string(),
203        serde_json::Value::Object(status_line),
204    );
205
206    settings.save(custom_dir)?;
207
208    println!("StatusLine wrapper installed successfully!");
209    println!("  Script: {}", wrapper_path.display());
210    println!("  Command: {}", wrapper_cmd);
211    println!();
212    println!("The current cc-switch alias name will now be displayed in the status line.");
213
214    if has_existing {
215        println!();
216        println!("Existing statusLine configuration detected and preserved.");
217    } else {
218        println!();
219        println!("To customize ccstatusline configuration, run one of:");
220        println!("  bunx -y ccstatusline@latest --help");
221        println!("  npx -y ccstatusline@latest --help");
222    }
223
224    Ok(())
225}
226
227/// Uninstall the statusLine wrapper script
228///
229/// Restores the original statusLine command and removes the wrapper script.
230pub fn uninstall(custom_dir: Option<&str>) -> Result<()> {
231    let wrapper_path = get_wrapper_script_path()?;
232
233    if !wrapper_path.exists() {
234        println!("StatusLine wrapper is not installed.");
235        return Ok(());
236    }
237
238    // Read original command from wrapper
239    let script_content =
240        fs::read_to_string(&wrapper_path).with_context(|| "Failed to read wrapper script")?;
241
242    let original_cmd = extract_original_cmd(&script_content);
243
244    // Update settings.json
245    let mut settings = ClaudeSettings::load(custom_dir)?;
246
247    if let Some(cmd) = original_cmd {
248        // Restore original command
249        if let Some(status_line) = settings.other.get_mut("statusLine")
250            && let Some(obj) = status_line.as_object_mut()
251        {
252            obj.insert(
253                "command".to_string(),
254                serde_json::Value::String(cmd.clone()),
255            );
256        }
257        println!("Restored original statusLine command: {}", cmd);
258    } else {
259        // No original command found, remove statusLine entirely
260        settings.other.remove("statusLine");
261        println!("Removed statusLine configuration (no original command found).");
262    }
263
264    settings.save(custom_dir)?;
265
266    // Remove wrapper script
267    fs::remove_file(&wrapper_path)
268        .with_context(|| format!("Failed to remove {}", wrapper_path.display()))?;
269
270    println!("StatusLine wrapper uninstalled successfully.");
271
272    Ok(())
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_generate_script() {
281        let cmd = "bunx -y ccstatusline@latest";
282        let script = generate_script(cmd);
283        assert!(script.contains("#!/usr/bin/env bash"));
284        assert!(script.contains(MARKER_PREFIX));
285        assert!(script.contains(cmd));
286        assert!(script.contains("cc_auto_switch_current_alias"));
287    }
288
289    #[test]
290    fn test_extract_original_cmd() {
291        let cmd = "bunx -y ccstatusline@latest";
292        let script = generate_script(cmd);
293        let extracted = extract_original_cmd(&script);
294        assert_eq!(extracted, Some(cmd.to_string()));
295    }
296
297    #[test]
298    fn test_extract_original_cmd_missing() {
299        let script = "#!/usr/bin/env bash\necho hello";
300        let extracted = extract_original_cmd(script);
301        assert_eq!(extracted, None);
302    }
303}