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