agentmux_srv\backend/
shellintegration.rs

1// Copyright 2026-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shell integration script deployment and shell startup configuration.
5//!
6//! Embeds shell integration scripts (bash, zsh, pwsh, fish) and deploys them to
7//! `~/.agentmux/shell/<type>/` on first use or when the version changes.
8//! The shell controller uses these scripts to install prompt hooks that send
9//! OSC 16162;E commands carrying `AGENTMUX_AGENT_ID`, enabling per-pane title
10//! and color to work.
11
12use std::path::Path;
13
14// ─── Embedded scripts ────────────────────────────────────────────────────────
15
16const BASH_SCRIPT: &str = include_str!("shellintegration/bash.sh");
17const ZSH_SCRIPT: &str = include_str!("shellintegration/zsh.sh");
18const PWSH_SCRIPT: &str = include_str!("shellintegration/pwsh.ps1");
19const FISH_SCRIPT: &str = include_str!("shellintegration/fish.fish");
20const VERSION_MARKER: &str = env!("CARGO_PKG_VERSION");
21
22// ─── Shell type ──────────────────────────────────────────────────────────────
23
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub enum ShellType {
26    Bash,
27    Zsh,
28    Pwsh,
29    Fish,
30    Unknown,
31}
32
33/// Detect shell type from the shell binary path.
34pub fn detect_shell_type(shell_path: &str) -> ShellType {
35    let name = Path::new(shell_path)
36        .file_stem()
37        .and_then(|s| s.to_str())
38        .unwrap_or("")
39        .to_lowercase();
40
41    match name.as_str() {
42        "pwsh" | "powershell" => ShellType::Pwsh,
43        "bash" => ShellType::Bash,
44        "zsh" => ShellType::Zsh,
45        "fish" => ShellType::Fish,
46        _ => ShellType::Unknown,
47    }
48}
49
50// ─── Deploy ──────────────────────────────────────────────────────────────────
51
52/// Deploy shell integration scripts to `<wave_data_dir>/shell/<type>/`.
53/// Skips deployment if the version marker is already current.
54/// Errors are logged but not fatal — a missing script just means no integration.
55pub fn deploy_scripts(wave_data_dir: &Path) {
56    let shell_base = wave_data_dir.join("shell");
57    let version_file = shell_base.join(".version");
58
59    // Check if already up-to-date
60    if let Ok(existing) = std::fs::read_to_string(&version_file) {
61        if existing.trim() == VERSION_MARKER {
62            return;
63        }
64    }
65
66    tracing::info!("Deploying shell integration scripts (v{})", VERSION_MARKER);
67
68    let deploys: &[(&str, &str, &str)] = &[
69        ("bash", ".bashrc", BASH_SCRIPT),
70        ("zsh", ".zshrc", ZSH_SCRIPT),
71        ("pwsh", "wavepwsh.ps1", PWSH_SCRIPT),
72        ("fish", "wave.fish", FISH_SCRIPT),
73    ];
74
75    let mut all_ok = true;
76    for (dir_name, file_name, content) in deploys {
77        let dir = shell_base.join(dir_name);
78        if let Err(e) = std::fs::create_dir_all(&dir) {
79            tracing::warn!("shell integration: failed to create {}: {}", dir.display(), e);
80            all_ok = false;
81            continue;
82        }
83        let path = dir.join(file_name);
84        if let Err(e) = std::fs::write(&path, content) {
85            tracing::warn!("shell integration: failed to write {}: {}", path.display(), e);
86            all_ok = false;
87        }
88    }
89
90    // Write version marker only if all scripts deployed successfully
91    if all_ok {
92        let _ = std::fs::write(&version_file, VERSION_MARKER);
93    }
94}
95
96// ─── Startup configuration ───────────────────────────────────────────────────
97
98/// Shell startup configuration: extra args and env vars to inject.
99pub struct ShellStartup {
100    /// Extra args to append to the shell command.
101    pub extra_args: Vec<String>,
102    /// Environment variables to set in the PTY.
103    pub env_vars: Vec<(String, String)>,
104}
105
106/// Get the startup configuration for launching an interactive shell with
107/// AgentMux integration. Returns `None` for unknown shell types.
108pub fn get_shell_startup(
109    shell_type: ShellType,
110    wave_data_dir: &Path,
111) -> Option<ShellStartup> {
112    match shell_type {
113        ShellType::Bash => {
114            let rcfile = wave_data_dir.join("shell").join("bash").join(".bashrc");
115            Some(ShellStartup {
116                extra_args: vec![
117                    "--rcfile".to_string(),
118                    rcfile.to_string_lossy().into_owned(),
119                ],
120                env_vars: vec![],
121            })
122        }
123        ShellType::Zsh => {
124            let zdotdir = wave_data_dir.join("shell").join("zsh");
125            Some(ShellStartup {
126                extra_args: vec![],
127                env_vars: vec![
128                    ("ZDOTDIR".to_string(), zdotdir.to_string_lossy().into_owned()),
129                    // Preserve original ZDOTDIR so the integration script can source ~/.zshrc
130                    ("AGENTMUX_ZDOTDIR".to_string(), zdotdir.to_string_lossy().into_owned()),
131                ],
132            })
133        }
134        ShellType::Pwsh => {
135            let script = wave_data_dir
136                .join("shell")
137                .join("pwsh")
138                .join("wavepwsh.ps1");
139            Some(ShellStartup {
140                extra_args: vec![
141                    "-ExecutionPolicy".to_string(),
142                    "Bypass".to_string(),
143                    "-NoExit".to_string(),
144                    "-File".to_string(),
145                    script.to_string_lossy().into_owned(),
146                ],
147                env_vars: vec![],
148            })
149        }
150        ShellType::Fish => {
151            let script = wave_data_dir
152                .join("shell")
153                .join("fish")
154                .join("wave.fish");
155            Some(ShellStartup {
156                extra_args: vec![
157                    "-C".to_string(),
158                    format!("source {}", shell_quote(&script.to_string_lossy())),
159                ],
160                env_vars: vec![],
161            })
162        }
163        ShellType::Unknown => None,
164    }
165}
166
167// wsh has been retired — see specs/SPEC_RETIRE_WSH_2026_04_12.md.
168// The `AGENTMUX` env var is now a plain "1" sentinel, not a path.
169
170// ─── Helpers ─────────────────────────────────────────────────────────────────
171
172/// Single-quote a path for POSIX shell usage.
173fn shell_quote(s: &str) -> String {
174    format!("'{}'", s.replace('\'', "'\\''"))
175}
176
177// ─── Tests ───────────────────────────────────────────────────────────────────
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_detect_shell_type() {
185        assert_eq!(detect_shell_type("bash"), ShellType::Bash);
186        assert_eq!(detect_shell_type("/bin/bash"), ShellType::Bash);
187        assert_eq!(detect_shell_type("zsh"), ShellType::Zsh);
188        assert_eq!(detect_shell_type("/usr/bin/zsh"), ShellType::Zsh);
189        assert_eq!(detect_shell_type("pwsh"), ShellType::Pwsh);
190        assert_eq!(detect_shell_type("powershell"), ShellType::Pwsh);
191        assert_eq!(detect_shell_type("fish"), ShellType::Fish);
192        assert_eq!(detect_shell_type("cmd.exe"), ShellType::Unknown);
193        assert_eq!(detect_shell_type("cmd"), ShellType::Unknown);
194    }
195
196    #[test]
197    fn test_bash_startup_args() {
198        let dir = Path::new("/home/user/.agentmux");
199        let startup = get_shell_startup(ShellType::Bash, dir).unwrap();
200        assert_eq!(startup.extra_args[0], "--rcfile");
201        assert!(startup.extra_args[1].contains("bash"));
202        assert!(startup.extra_args[1].ends_with(".bashrc"));
203    }
204
205    #[test]
206    fn test_pwsh_startup_args() {
207        let dir = Path::new("/home/user/.agentmux");
208        let startup = get_shell_startup(ShellType::Pwsh, dir).unwrap();
209        assert!(startup.extra_args.contains(&"-NoExit".to_string()));
210        assert!(startup.extra_args.contains(&"-File".to_string()));
211    }
212
213    #[test]
214    fn test_zsh_uses_zdotdir() {
215        let dir = Path::new("/home/user/.agentmux");
216        let startup = get_shell_startup(ShellType::Zsh, dir).unwrap();
217        assert!(startup.extra_args.is_empty());
218        assert!(startup.env_vars.iter().any(|(k, _)| k == "ZDOTDIR"));
219    }
220
221    #[test]
222    fn test_unknown_shell_returns_none() {
223        let dir = Path::new("/tmp");
224        assert!(get_shell_startup(ShellType::Unknown, dir).is_none());
225    }
226}