1use 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
17const DEFAULT_STATUSLINE_CMD: &str = "bunx -y ccstatusline@latest";
19
20const MARKER_PREFIX: &str = "# CC_SWITCH_ORIGINAL_CMD: ";
22
23fn 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
34fn 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
51fn 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
60fn 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
139fn 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
151pub 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 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 if current_cmd.contains("cc_auto_switch_statusline.sh") {
181 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 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 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 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 let script = generate_script(&original_cmd);
239
240 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 fs::write(&wrapper_path, &script).with_context(|| {
248 format!(
249 "Failed to write wrapper script to {}",
250 wrapper_path.display()
251 )
252 })?;
253
254 #[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 let wrapper_cmd = format!("bash {}", wrapper_path.display());
264
265 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 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
314pub 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 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 let mut settings = ClaudeSettings::load(custom_dir)?;
333
334 if let Some(cmd) = original_cmd {
335 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 settings.other.remove("statusLine");
348 println!("Removed statusLine configuration (no original command found).");
349 }
350
351 settings.save(custom_dir)?;
352
353 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 let cmd = "bunx -y ccstatusline@latest";
402 let script = generate_script(cmd);
403
404 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 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 let cmd = "bunx -y ccstatusline@latest";
428 let script = generate_script(cmd);
429
430 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}