1use 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
15const DEFAULT_STATUSLINE_CMD: &str = "bunx -y ccstatusline@latest";
17
18const MARKER_PREFIX: &str = "# CC_SWITCH_ORIGINAL_CMD: ";
20
21fn 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
32fn 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
49fn 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
58fn 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
82fn 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
94pub fn install(custom_dir: Option<&str>) -> Result<()> {
103 let mut settings = ClaudeSettings::load(custom_dir)?;
104
105 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 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 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 let script = generate_script(&original_cmd);
152
153 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 fs::write(&wrapper_path, &script).with_context(|| {
161 format!(
162 "Failed to write wrapper script to {}",
163 wrapper_path.display()
164 )
165 })?;
166
167 #[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 let wrapper_cmd = format!("bash {}", wrapper_path.display());
177
178 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 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
227pub 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 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 let mut settings = ClaudeSettings::load(custom_dir)?;
246
247 if let Some(cmd) = original_cmd {
248 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 settings.other.remove("statusLine");
261 println!("Removed statusLine configuration (no original command found).");
262 }
263
264 settings.save(custom_dir)?;
265
266 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}