1use std::path::{Path, PathBuf};
14
15const BASH_SCRIPT: &str = include_str!("shellintegration/bash.sh");
18const ZSH_SCRIPT: &str = include_str!("shellintegration/zsh.sh");
19const PWSH_SCRIPT: &str = include_str!("shellintegration/pwsh.ps1");
20const FISH_SCRIPT: &str = include_str!("shellintegration/fish.fish");
21const MUXLOG_JS: &str = include_str!("shellintegration/muxlog.mjs");
25const MUXSPECT_JS: &str = include_str!("shellintegration/muxspect.mjs");
29const MUXOPEN_JS: &str = include_str!("shellintegration/muxopen.mjs");
34const MUXSH_JS: &str = include_str!("shellintegration/muxsh.mjs");
39const MUXCLIENT_JS: &str = include_str!("shellintegration/lib/muxclient.mjs");
45
46fn version_marker() -> String {
59 use std::collections::hash_map::DefaultHasher;
60 use std::hash::{Hash, Hasher};
61 let mut hasher = DefaultHasher::new();
62 BASH_SCRIPT.hash(&mut hasher);
63 ZSH_SCRIPT.hash(&mut hasher);
64 PWSH_SCRIPT.hash(&mut hasher);
65 FISH_SCRIPT.hash(&mut hasher);
66 MUXLOG_JS.hash(&mut hasher);
67 MUXSPECT_JS.hash(&mut hasher);
68 MUXOPEN_JS.hash(&mut hasher);
73 MUXSH_JS.hash(&mut hasher);
74 MUXCLIENT_JS.hash(&mut hasher);
75 format!("{}-{:x}", env!("CARGO_PKG_VERSION"), hasher.finish())
76}
77
78#[derive(Debug, Clone, Copy, PartialEq)]
81pub enum ShellType {
82 Bash,
83 Zsh,
84 Pwsh,
85 Fish,
86 Unknown,
87}
88
89pub fn detect_shell_type(shell_path: &str) -> ShellType {
91 let name = Path::new(shell_path)
92 .file_stem()
93 .and_then(|s| s.to_str())
94 .unwrap_or("")
95 .to_lowercase();
96
97 match name.as_str() {
98 "pwsh" | "powershell" => ShellType::Pwsh,
99 "bash" => ShellType::Bash,
100 "zsh" => ShellType::Zsh,
101 "fish" => ShellType::Fish,
102 _ => ShellType::Unknown,
103 }
104}
105
106pub fn integration_base_for(data_dir: &Path, home_dir: &Path) -> PathBuf {
129 let canonical = data_dir.canonicalize().unwrap_or_else(|_| data_dir.to_path_buf());
130 let key = format!("{:016x}", agentmux_common::runtime_mode::fnv1a_64(
131 canonical.to_string_lossy().to_lowercase().as_bytes(),
132 ));
133 home_dir.join(".agentmux").join("shell").join(key)
134}
135
136pub fn documented_shell_root() -> PathBuf {
145 crate::backend::base::get_mux_data_dir().join("shell")
146}
147
148pub fn integration_base() -> PathBuf {
160 integration_base_for(
161 &crate::backend::base::get_mux_data_dir(),
162 &crate::backend::base::get_home_dir(),
163 )
164}
165
166pub fn deploy_scripts(shell_root: &Path) {
167 let shell_base = shell_root.to_path_buf();
168 let version_file = shell_base.join(".version");
169 let marker = version_marker();
170
171 if let Ok(existing) = std::fs::read_to_string(&version_file) {
173 if existing.trim() == marker {
174 return;
175 }
176 }
177
178 tracing::info!("Deploying shell integration scripts ({})", marker);
179
180 let deploys: &[(&str, &str, &str)] = &[
181 ("bash", ".bashrc", BASH_SCRIPT),
182 ("zsh", ".zshrc", ZSH_SCRIPT),
183 ("pwsh", "wavepwsh.ps1", PWSH_SCRIPT),
184 ("fish", "wave.fish", FISH_SCRIPT),
185 ];
186
187 let mut all_ok = true;
188 for (dir_name, file_name, content) in deploys {
189 let dir = shell_base.join(dir_name);
190 if let Err(e) = std::fs::create_dir_all(&dir) {
191 tracing::warn!("shell integration: failed to create {}: {}", dir.display(), e);
192 all_ok = false;
193 continue;
194 }
195 let path = dir.join(file_name);
196 if let Err(e) = std::fs::write(&path, content) {
197 tracing::warn!("shell integration: failed to write {}: {}", path.display(), e);
198 all_ok = false;
199 }
200 }
201
202 let muxlog_path = shell_base.join("muxlog.mjs");
205 if let Err(e) = std::fs::write(&muxlog_path, MUXLOG_JS) {
206 tracing::warn!("shell integration: failed to write {}: {}", muxlog_path.display(), e);
207 all_ok = false;
208 }
209
210 let muxspect_path = shell_base.join("muxspect.mjs");
212 if let Err(e) = std::fs::write(&muxspect_path, MUXSPECT_JS) {
213 tracing::warn!("shell integration: failed to write {}: {}", muxspect_path.display(), e);
214 }
215 let muxopen_path = shell_base.join("muxopen.mjs");
217 if let Err(e) = std::fs::write(&muxopen_path, MUXOPEN_JS) {
218 tracing::warn!("shell integration: failed to write {}: {}", muxopen_path.display(), e);
219 all_ok = false;
220 }
221 let muxsh_path = shell_base.join("muxsh.mjs");
223 if let Err(e) = std::fs::write(&muxsh_path, MUXSH_JS) {
224 tracing::warn!("shell integration: failed to write {}: {}", muxsh_path.display(), e);
225 all_ok = false;
226 }
227 let lib_dir = shell_base.join("lib");
231 if let Err(e) = std::fs::create_dir_all(&lib_dir) {
232 tracing::warn!("shell integration: failed to create {}: {}", lib_dir.display(), e);
233 all_ok = false;
234 } else {
235 let muxclient_path = lib_dir.join("muxclient.mjs");
236 if let Err(e) = std::fs::write(&muxclient_path, MUXCLIENT_JS) {
237 tracing::warn!("shell integration: failed to write {}: {}", muxclient_path.display(), e);
238 all_ok = false;
239 }
240 }
241
242 if all_ok {
244 let _ = std::fs::write(&version_file, &marker);
245 }
246}
247
248pub struct ShellStartup {
252 pub extra_args: Vec<String>,
254 pub env_vars: Vec<(String, String)>,
256}
257
258pub fn get_shell_startup(
261 shell_type: ShellType,
262 shell_root: &Path,
263) -> Option<ShellStartup> {
264 match shell_type {
265 ShellType::Bash => {
266 let rcfile = shell_root.join("bash").join(".bashrc");
267 Some(ShellStartup {
268 extra_args: vec![
269 "--rcfile".to_string(),
270 rcfile.to_string_lossy().into_owned(),
271 ],
272 env_vars: vec![],
273 })
274 }
275 ShellType::Zsh => {
276 let zdotdir = shell_root.join("zsh");
277 Some(ShellStartup {
278 extra_args: vec![],
279 env_vars: vec![
280 ("ZDOTDIR".to_string(), zdotdir.to_string_lossy().into_owned()),
281 ("AGENTMUX_ZDOTDIR".to_string(), zdotdir.to_string_lossy().into_owned()),
283 ],
284 })
285 }
286 ShellType::Pwsh => {
287 let script = shell_root
288 .join("pwsh")
289 .join("wavepwsh.ps1");
290 Some(ShellStartup {
291 extra_args: vec![
292 "-ExecutionPolicy".to_string(),
293 "Bypass".to_string(),
294 "-NoExit".to_string(),
295 "-File".to_string(),
296 script.to_string_lossy().into_owned(),
297 ],
298 env_vars: vec![],
299 })
300 }
301 ShellType::Fish => {
302 let script = shell_root
303 .join("fish")
304 .join("wave.fish");
305 Some(ShellStartup {
306 extra_args: vec![
307 "-C".to_string(),
308 format!("source {}", agentmux_common::shell_quote::posix_single_quote(&script.to_string_lossy())),
309 ],
310 env_vars: vec![],
311 })
312 }
313 ShellType::Unknown => None,
314 }
315}
316
317#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn test_detect_shell_type() {
330 assert_eq!(detect_shell_type("bash"), ShellType::Bash);
331 assert_eq!(detect_shell_type("/bin/bash"), ShellType::Bash);
332 assert_eq!(detect_shell_type("zsh"), ShellType::Zsh);
333 assert_eq!(detect_shell_type("/usr/bin/zsh"), ShellType::Zsh);
334 assert_eq!(detect_shell_type("pwsh"), ShellType::Pwsh);
335 assert_eq!(detect_shell_type("powershell"), ShellType::Pwsh);
336 assert_eq!(detect_shell_type("fish"), ShellType::Fish);
337 assert_eq!(detect_shell_type("cmd.exe"), ShellType::Unknown);
338 assert_eq!(detect_shell_type("cmd"), ShellType::Unknown);
339 }
340
341 #[test]
342 fn test_bash_startup_args() {
343 let dir = Path::new("/home/user/.agentmux");
344 let startup = get_shell_startup(ShellType::Bash, dir).unwrap();
345 assert_eq!(startup.extra_args[0], "--rcfile");
346 assert!(startup.extra_args[1].contains("bash"));
347 assert!(startup.extra_args[1].ends_with(".bashrc"));
348 }
349
350 #[test]
351 fn test_pwsh_startup_args() {
352 let dir = Path::new("/home/user/.agentmux");
353 let startup = get_shell_startup(ShellType::Pwsh, dir).unwrap();
354 assert!(startup.extra_args.contains(&"-NoExit".to_string()));
355 assert!(startup.extra_args.contains(&"-File".to_string()));
356 }
357
358 #[test]
359 fn test_zsh_uses_zdotdir() {
360 let dir = Path::new("/home/user/.agentmux");
361 let startup = get_shell_startup(ShellType::Zsh, dir).unwrap();
362 assert!(startup.extra_args.is_empty());
363 assert!(startup.env_vars.iter().any(|(k, _)| k == "ZDOTDIR"));
364 }
365
366 #[test]
367 fn test_unknown_shell_returns_none() {
368 let dir = Path::new("/tmp");
369 assert!(get_shell_startup(ShellType::Unknown, dir).is_none());
370 }
371
372 #[test]
376 fn deploy_scripts_writes_muxlog_and_muxspect_side_by_side() {
377 let tmp = std::env::temp_dir().join(format!(
378 "agentmux-shellintegration-test-{}",
379 std::process::id()
380 ));
381 let _ = std::fs::remove_dir_all(&tmp);
382 deploy_scripts(&tmp.join("shell"));
383
384 let shell_base = tmp.join("shell");
385 let muxlog = shell_base.join("muxlog.mjs");
386 let muxspect = shell_base.join("muxspect.mjs");
387 let muxopen = shell_base.join("muxopen.mjs");
388 let muxsh = shell_base.join("muxsh.mjs");
389 let muxclient = shell_base.join("lib").join("muxclient.mjs");
390 assert!(muxlog.exists(), "muxlog.mjs should be deployed");
391 assert!(muxspect.exists(), "muxspect.mjs should be deployed alongside it");
392 assert!(muxopen.exists(), "muxopen.mjs should be deployed alongside them");
393 assert!(muxsh.exists(), "muxsh.mjs should be deployed alongside them");
394 assert!(
395 muxclient.exists(),
396 "lib/muxclient.mjs should be deployed — muxspect/muxopen/muxsh import it via a relative path"
397 );
398 assert_eq!(std::fs::read_to_string(&muxspect).unwrap(), MUXSPECT_JS);
399 assert_eq!(std::fs::read_to_string(&muxsh).unwrap(), MUXSH_JS);
400 assert_eq!(std::fs::read_to_string(&muxclient).unwrap(), MUXCLIENT_JS);
401
402 let _ = std::fs::remove_dir_all(&tmp);
403 }
404
405 #[test]
412 fn deploy_scripts_redeploys_when_marker_predates_a_content_change() {
413 let tmp = std::env::temp_dir().join(format!(
414 "agentmux-shellintegration-test-stale-{}",
415 std::process::id()
416 ));
417 let _ = std::fs::remove_dir_all(&tmp);
418 let shell_base = tmp.join("shell");
419 std::fs::create_dir_all(&shell_base).unwrap();
420
421 std::fs::write(shell_base.join(".version"), env!("CARGO_PKG_VERSION")).unwrap();
426 assert!(!shell_base.join("muxspect.mjs").exists(), "precondition: not deployed yet");
427
428 deploy_scripts(&tmp.join("shell"));
429
430 assert!(
431 shell_base.join("muxspect.mjs").exists(),
432 "a stale version-only marker must not suppress deployment of a script that was never written"
433 );
434 assert_eq!(
435 std::fs::read_to_string(shell_base.join(".version")).unwrap(),
436 version_marker(),
437 "marker must be updated to the new content-hashed form"
438 );
439
440 let _ = std::fs::remove_dir_all(&tmp);
441 }
442
443 #[test]
449 fn two_instances_never_share_a_shell_integration_dir() {
450 let home = Path::new("/home/u");
451 let a = integration_base_for(Path::new("/ch/alpha/versions/0.56.2/data"), home);
452 let b = integration_base_for(Path::new("/ch/alpha/versions/0.56.3/data"), home);
453 let c = integration_base_for(Path::new("/ch/beta/versions/0.56.3/data"), home);
454 assert_ne!(a, b, "same channel, different version must not collide");
455 assert_ne!(b, c, "same version, different channel must not collide");
456 assert_ne!(a, c);
457 }
458
459 #[test]
462 fn the_same_instance_always_resolves_to_the_same_dir() {
463 let home = Path::new("/home/u");
464 let d = Path::new("/ch/alpha/versions/0.56.3/data");
465 assert_eq!(integration_base_for(d, home), integration_base_for(d, home));
466 }
467
468 #[test]
471 fn deployment_stays_under_the_never_virtualised_home_dir() {
472 let home = Path::new("/home/u");
473 let base = integration_base_for(Path::new("/ch/alpha/versions/0.56.3/data"), home);
474 assert!(base.starts_with(home), "must live under home, got {}", base.display());
475 assert!(!base.starts_with("/ch/alpha"), "must not live under the data dir");
476 }
477
478 #[test]
485 fn the_deployed_rcfile_is_exactly_the_one_the_shell_is_told_to_load() {
486 let tmp = std::env::temp_dir().join(format!("am-si-couple-{}", std::process::id()));
487 let _ = std::fs::remove_dir_all(&tmp);
488 let root = tmp.join("shell");
489 deploy_scripts(&root);
490
491 let startup = get_shell_startup(ShellType::Bash, &root).expect("bash startup");
492 assert_eq!(startup.extra_args[0], "--rcfile");
493 let rcfile = Path::new(&startup.extra_args[1]);
494 assert!(
495 rcfile.exists(),
496 "shell is told to load {} but deploy_scripts never wrote it",
497 rcfile.display()
498 );
499
500 let body = std::fs::read_to_string(rcfile).expect("read rcfile");
502 assert!(body.contains("_agentmux_si_prompt_command"), "rcfile is not the integration script");
503
504 let _ = std::fs::remove_dir_all(&tmp);
505 }
506}