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::path::PathBuf;
10use std::process::Command;
11
12#[cfg(unix)]
13use std::os::unix::fs::PermissionsExt;
14
15use crate::config::types::ClaudeSettings;
16
17/// Default statusLine command if none is configured
18const DEFAULT_STATUSLINE_CMD: &str = "bunx -y ccstatusline@latest";
19
20/// Marker comment prefix for storing original command
21const MARKER_PREFIX: &str = "# CC_SWITCH_ORIGINAL_CMD: ";
22
23/// Check if a command is available in PATH
24fn is_command_available(cmd: &str) -> bool {
25    Command::new("which")
26        .arg(cmd)
27        .stdout(std::process::Stdio::null())
28        .stderr(std::process::Stdio::null())
29        .status()
30        .map(|s| s.success())
31        .unwrap_or(false)
32}
33
34/// Detect available package manager for running ccstatusline
35///
36/// Priority:
37/// 1. bun (bunx) - faster
38/// 2. npm (npx) - fallback
39///
40/// Returns the command string to use, or None if neither is available.
41fn detect_statusline_runner() -> Option<&'static str> {
42    if is_command_available("bun") {
43        Some("bunx -y ccstatusline@latest")
44    } else if is_command_available("npx") {
45        Some("npx -y ccstatusline@latest")
46    } else {
47        None
48    }
49}
50
51/// Get the path to the wrapper script
52fn get_wrapper_script_path() -> Result<PathBuf> {
53    let config_file = crate::config::get_config_storage_path()?;
54    let config_dir = config_file
55        .parent()
56        .context("Could not get config directory")?;
57    Ok(config_dir.join("cc_auto_switch_statusline.sh"))
58}
59
60/// Generate the wrapper script content
61fn generate_script(original_cmd: &str) -> String {
62    let encoded = base64::engine::general_purpose::STANDARD.encode(original_cmd);
63    format!(
64        r#"#!/usr/bin/env bash
65{marker}{encoded}
66
67# Clean up orphaned alias files for dead processes (runs in background to avoid latency)
68cleanup_orphans() {{
69  for f in "$HOME/.claude/cc_auto_tmp_pid/cc_auto_switch_alias_"*; do
70    [ -f "$f" ] || continue
71    local pid="${{f##*_}}"
72    if ! kill -0 "$pid" 2>/dev/null; then
73      rm -f "$f"
74    fi
75  done
76}}
77cleanup_orphans &
78
79# Traverse parent process chain to find the real Claude process
80# Claude Code spawns statusLine via an intermediate process, so $PPID is not
81# the Claude main process. We walk up the process tree to find a process
82# whose name contains 'claude' or 'node' and has a per-PID alias file.
83find_claude_pid() {{
84  local current_pid=$PPID
85  local max_depth=10
86  local depth=0
87
88  while [ $depth -lt $max_depth ] && [ $current_pid -gt 1 ]; do
89    local proc_info=$(ps -p $current_pid -o pid,ppid,comm 2>/dev/null | tail -1)
90    if [ -z "$proc_info" ]; then
91      break
92    fi
93
94    local pid=$(echo "$proc_info" | awk '{{print $1}}')
95    local ppid=$(echo "$proc_info" | awk '{{print $2}}')
96    local comm=$(echo "$proc_info" | awk '{{print $3}}')
97
98    # Check if this is claude or node (Claude Code runs on Node.js)
99    if [[ "$comm" == *"claude"* ]] || [[ "$comm" == *"node"* ]]; then
100      if [ -f "$HOME/.claude/cc_auto_tmp_pid/cc_auto_switch_alias_${{pid}}" ]; then
101        echo $pid
102        return 0
103      fi
104    fi
105
106    current_pid=$ppid
107    depth=$((depth + 1))
108  done
109  return 1
110}}
111
112alias_name=""
113# Priority: env var (per-session, most reliable) > per-PID file (per-session)
114# The env var CC_SWITCH_CURRENT_ALIAS is set by cc-switch when launching Claude and inherited
115# by all child processes. It is the most reliable source because it is per-session and cannot
116# be contaminated by other sessions. The per-PID file is a fallback for sessions where the
117# env var is not available. The global file is NOT used as a fallback because it is shared
118# across all sessions and overwritten by every `cs use` invocation, which would cause
119# cross-session alias contamination.
120if [ -n "$CC_SWITCH_CURRENT_ALIAS" ]; then
121  alias_name="$CC_SWITCH_CURRENT_ALIAS"
122else
123  claude_pid=$(find_claude_pid)
124  if [ -n "$claude_pid" ] && [ -f "$HOME/.claude/cc_auto_tmp_pid/cc_auto_switch_alias_${{claude_pid}}" ]; then
125    alias_name=$(cat "$HOME/.claude/cc_auto_tmp_pid/cc_auto_switch_alias_${{claude_pid}}" 2>/dev/null)
126  fi
127fi
128if [ -n "$alias_name" ]; then
129  printf '[%s] ' "$alias_name"
130fi
131{original_cmd}
132"#,
133        marker = MARKER_PREFIX,
134        encoded = encoded,
135        original_cmd = original_cmd,
136    )
137}
138
139/// Extract the original command from a wrapper script
140fn extract_original_cmd(script_content: &str) -> Option<String> {
141    for line in script_content.lines() {
142        if let Some(encoded) = line.strip_prefix(MARKER_PREFIX)
143            && let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded)
144        {
145            return String::from_utf8(decoded).ok();
146        }
147    }
148    None
149}
150
151/// Install the statusLine wrapper script
152///
153/// Reads the current statusLine command from settings.json, generates a wrapper
154/// script that prepends the current alias name, and updates settings.json to
155/// use the wrapper script.
156///
157/// If no statusLine is configured, it will detect the available package manager
158/// (bun or npm) and use the appropriate command.
159pub fn install(custom_dir: Option<&str>) -> Result<()> {
160    let mut settings = ClaudeSettings::load(custom_dir)?;
161    let wrapper_path = get_wrapper_script_path()?;
162
163    // Get current statusLine command, or detect available runner if none configured
164    let has_existing = settings
165        .other
166        .get("statusLine")
167        .and_then(|v| v.get("command"))
168        .is_some();
169
170    let original_cmd = if has_existing {
171        let current_cmd = settings
172            .other
173            .get("statusLine")
174            .and_then(|v| v.get("command"))
175            .and_then(|v| v.as_str())
176            .unwrap_or(DEFAULT_STATUSLINE_CMD)
177            .to_string();
178
179        // Check if current command is the wrapper script itself (recursive installation)
180        if current_cmd.contains("cc_auto_switch_statusline.sh") {
181            // Try to extract original command from existing wrapper
182            if wrapper_path.exists()
183                && let Ok(existing) = fs::read_to_string(&wrapper_path)
184                && let Some(existing_cmd) = extract_original_cmd(&existing)
185            {
186                existing_cmd
187            } else {
188                // No existing wrapper or can't extract, detect package manager
189                match detect_statusline_runner() {
190                    Some(cmd) => {
191                        println!(
192                            "Detected package manager: {}",
193                            if cmd.contains("bun") { "bun" } else { "npm" }
194                        );
195                        cmd.to_string()
196                    }
197                    None => {
198                        anyhow::bail!(
199                            "No package manager found (bun or npm required for ccstatusline).\n\
200                             Please install bun or npm, then run: cc-switch statusline install"
201                        );
202                    }
203                }
204            }
205        } else {
206            current_cmd
207        }
208    } else {
209        // No existing statusLine, detect available package manager
210        match detect_statusline_runner() {
211            Some(cmd) => {
212                println!(
213                    "Detected package manager: {}",
214                    if cmd.contains("bun") { "bun" } else { "npm" }
215                );
216                cmd.to_string()
217            }
218            None => {
219                anyhow::bail!(
220                    "No package manager found (bun or npm required for ccstatusline).\n\
221                     Please install bun or npm, then run: cc-switch statusline install"
222                );
223            }
224        }
225    };
226
227    // Check if already installed with same command
228    if wrapper_path.exists()
229        && let Ok(existing) = fs::read_to_string(&wrapper_path)
230        && let Some(existing_cmd) = extract_original_cmd(&existing)
231        && existing_cmd == original_cmd
232    {
233        println!("StatusLine wrapper already installed with the same command.");
234        return Ok(());
235    }
236
237    // Generate wrapper script
238    let script = generate_script(&original_cmd);
239
240    // Create directory if needed
241    if let Some(parent) = wrapper_path.parent() {
242        fs::create_dir_all(parent)
243            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
244    }
245
246    // Write script
247    fs::write(&wrapper_path, &script).with_context(|| {
248        format!(
249            "Failed to write wrapper script to {}",
250            wrapper_path.display()
251        )
252    })?;
253
254    // Make executable
255    #[cfg(unix)]
256    {
257        let mut perms = fs::metadata(&wrapper_path)?.permissions();
258        perms.set_mode(0o755);
259        fs::set_permissions(&wrapper_path, perms)?;
260    }
261
262    // Update settings.json
263    let wrapper_cmd = format!("bash {}", wrapper_path.display());
264
265    // Build new statusLine object
266    let mut status_line = serde_json::Map::new();
267    status_line.insert(
268        "type".to_string(),
269        serde_json::Value::String("command".to_string()),
270    );
271    status_line.insert(
272        "command".to_string(),
273        serde_json::Value::String(wrapper_cmd.clone()),
274    );
275
276    // Preserve padding if it existed
277    if let Some(existing) = settings.other.get("statusLine") {
278        if let Some(padding) = existing.get("padding") {
279            status_line.insert("padding".to_string(), padding.clone());
280        }
281    } else {
282        status_line.insert(
283            "padding".to_string(),
284            serde_json::Value::Number(serde_json::Number::from(0)),
285        );
286    }
287
288    settings.other.insert(
289        "statusLine".to_string(),
290        serde_json::Value::Object(status_line),
291    );
292
293    settings.save(custom_dir)?;
294
295    println!("StatusLine wrapper installed successfully!");
296    println!("  Script: {}", wrapper_path.display());
297    println!("  Command: {}", wrapper_cmd);
298    println!();
299    println!("The current cc-switch alias name will now be displayed in the status line.");
300
301    if has_existing {
302        println!();
303        println!("Existing statusLine configuration detected and preserved.");
304    } else {
305        println!();
306        println!("To customize ccstatusline configuration, run one of:");
307        println!("  bunx -y ccstatusline@latest --help");
308        println!("  npx -y ccstatusline@latest --help");
309    }
310
311    Ok(())
312}
313
314/// Uninstall the statusLine wrapper script
315///
316/// Restores the original statusLine command and removes the wrapper script.
317pub fn uninstall(custom_dir: Option<&str>) -> Result<()> {
318    let wrapper_path = get_wrapper_script_path()?;
319
320    if !wrapper_path.exists() {
321        println!("StatusLine wrapper is not installed.");
322        return Ok(());
323    }
324
325    // Read original command from wrapper
326    let script_content =
327        fs::read_to_string(&wrapper_path).with_context(|| "Failed to read wrapper script")?;
328
329    let original_cmd = extract_original_cmd(&script_content);
330
331    // Update settings.json
332    let mut settings = ClaudeSettings::load(custom_dir)?;
333
334    if let Some(cmd) = original_cmd {
335        // Restore original command
336        if let Some(status_line) = settings.other.get_mut("statusLine")
337            && let Some(obj) = status_line.as_object_mut()
338        {
339            obj.insert(
340                "command".to_string(),
341                serde_json::Value::String(cmd.clone()),
342            );
343        }
344        println!("Restored original statusLine command: {}", cmd);
345    } else {
346        // No original command found, remove statusLine entirely
347        settings.other.remove("statusLine");
348        println!("Removed statusLine configuration (no original command found).");
349    }
350
351    settings.save(custom_dir)?;
352
353    // Remove wrapper script
354    fs::remove_file(&wrapper_path)
355        .with_context(|| format!("Failed to remove {}", wrapper_path.display()))?;
356
357    println!("StatusLine wrapper uninstalled successfully.");
358
359    Ok(())
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_generate_script() {
368        let cmd = "bunx -y ccstatusline@latest";
369        let script = generate_script(cmd);
370        assert!(script.contains("#!/usr/bin/env bash"));
371        assert!(script.contains(MARKER_PREFIX));
372        assert!(script.contains(cmd));
373        assert!(script.contains("CC_SWITCH_CURRENT_ALIAS"));
374    }
375
376    #[test]
377    fn test_extract_original_cmd() {
378        let cmd = "bunx -y ccstatusline@latest";
379        let script = generate_script(cmd);
380        let extracted = extract_original_cmd(&script);
381        assert_eq!(extracted, Some(cmd.to_string()));
382    }
383
384    #[test]
385    fn test_extract_original_cmd_missing() {
386        let script = "#!/usr/bin/env bash\necho hello";
387        let extracted = extract_original_cmd(script);
388        assert_eq!(extracted, None);
389    }
390
391    #[test]
392    fn test_env_var_has_highest_priority_in_script() {
393        // The $CC_SWITCH_CURRENT_ALIAS env var is set per-session by cs use and
394        // inherited by the statusline subprocess. It must be checked BEFORE the
395        // per-PID file and global file lookups, because:
396        // 1. Per-PID files don't exist for sessions launched before the feature
397        // 2. find_claude_pid() can match the wrong claude process when many
398        //    sessions are running
399        // 3. The global file is overwritten by every cs use in any terminal,
400        //    which would change the alias shown in ALL running sessions
401        let cmd = "bunx -y ccstatusline@latest";
402        let script = generate_script(cmd);
403
404        // Find the position of the env var check and the per-PID file check
405        let env_var_pos = script
406            .find("CC_SWITCH_CURRENT_ALIAS")
407            .expect("Script must reference CC_SWITCH_CURRENT_ALIAS");
408        let find_claude_pid_call_pos = script
409            .find("$(find_claude_pid)")
410            .expect("Script must call find_claude_pid");
411
412        // The env var check must come BEFORE the find_claude_pid() call
413        assert!(
414            env_var_pos < find_claude_pid_call_pos,
415            "CC_SWITCH_CURRENT_ALIAS env var must be checked before find_claude_pid() \
416             to prevent cross-session alias contamination"
417        );
418    }
419
420    #[test]
421    fn test_global_file_not_used_as_fallback() {
422        // The global file cc_auto_switch_current_alias is shared across all sessions
423        // and overwritten by every `cs use` invocation. Using it as a fallback would
424        // cause cross-session alias contamination: running `cs use xxx` in one terminal
425        // would change the alias displayed in ALL running sessions.
426        // The script must NOT read from the global file.
427        let cmd = "bunx -y ccstatusline@latest";
428        let script = generate_script(cmd);
429
430        // The alias detection logic (after find_claude_pid function) must not reference
431        // the global file. The find_claude_pid function body references per-PID files
432        // (cc_auto_switch_alias_${pid}) which is fine.
433        let alias_detection_section = script
434            .split("alias_name=\"\"")
435            .nth(1)
436            .expect("Script must have alias detection section");
437
438        assert!(
439            !alias_detection_section.contains("cc_auto_switch_current_alias"),
440            "The alias detection section must NOT use the global file \
441             (cc_auto_switch_current_alias) as a fallback, because it is shared \
442             across all sessions and causes cross-session contamination"
443        );
444    }
445}