Files
agent-manager/src/shell.rs
T

297 lines
9.4 KiB
Rust

//! System-shell gateway.
//!
//! The interactive prompt doubles as a gateway between the user and their
//! system shell: every input that is not an am command is executed by the
//! selected shell (pwsh, powershell, cmd, bash, zsh, fish, sh, ...).
//!
//! The default shell is resolved at startup from, in order: the
//! `settings.default_shell` configuration entry, the `SHELL` environment
//! variable, `COMSPEC` (Windows), then the first shell found on PATH among
//! the platform's preferred shells. `shell <name>` switches at runtime.
use crate::config::Config;
use anyhow::{anyhow, Result};
use std::ffi::OsString;
use std::path::Path;
use std::process::Command as ProcessCommand;
/// One supported system shell and how to invoke it with a command line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShellSpec {
/// Canonical short name ("pwsh", "cmd", "bash", ...).
pub name: &'static str,
/// Human-readable title ("PowerShell 7", "Command Prompt", ...).
pub title: &'static str,
/// Executable to spawn.
pub program: &'static str,
/// Arguments placed before the user's command line.
pub args: &'static [&'static str],
/// Executable probed on PATH to decide whether the shell is available.
pub probe: &'static str,
}
/// Every shell the gateway understands, in display order.
pub static KNOWN_SHELLS: &[ShellSpec] = &[
ShellSpec {
name: "pwsh",
title: "PowerShell 7",
program: "pwsh",
args: &["-NoLogo", "-NoProfile", "-Command"],
probe: "pwsh",
},
ShellSpec {
name: "powershell",
title: "Windows PowerShell",
program: "powershell",
args: &["-NoLogo", "-NoProfile", "-Command"],
probe: "powershell",
},
ShellSpec {
name: "cmd",
title: "Command Prompt",
program: "cmd",
args: &["/C"],
probe: "cmd",
},
ShellSpec {
name: "bash",
title: "Bash",
program: "bash",
args: &["-c"],
probe: "bash",
},
ShellSpec {
name: "zsh",
title: "Zsh",
program: "zsh",
args: &["-c"],
probe: "zsh",
},
ShellSpec {
name: "fish",
title: "Fish",
program: "fish",
args: &["-c"],
probe: "fish",
},
ShellSpec {
name: "sh",
title: "POSIX sh",
program: "sh",
args: &["-c"],
probe: "sh",
},
ShellSpec {
name: "nu",
title: "Nushell",
program: "nu",
args: &["-c"],
probe: "nu",
},
ShellSpec {
name: "elvish",
title: "Elvish",
program: "elvish",
args: &["-c"],
probe: "elvish",
},
];
/// Resolve a shell by canonical name or common alias, case-insensitively
/// ("pwsh" | "powershell7" | "ps" | "cmd.exe" | "nushell" | ...).
pub fn find(name: &str) -> Option<&'static ShellSpec> {
let mut n = name.trim().to_ascii_lowercase();
if let Some(stripped) = n.strip_suffix(".exe") {
n = stripped.to_string();
}
KNOWN_SHELLS.iter().find(|s| {
s.name == n
|| match s.name {
"pwsh" => n == "powershell7" || n == "ps7",
"powershell" => n == "ps",
"cmd" => n == "dos" || n == "command" || n == "command-prompt",
"sh" => n == "posix" || n == "dash",
"nu" => n == "nushell",
_ => false,
}
})
}
/// Whether a shell executable can be found (or, for cmd on Windows, whether
/// COMSPEC is set).
pub fn probe_available(spec: &ShellSpec) -> bool {
which::which(spec.probe).is_ok()
|| (cfg!(windows) && spec.name == "cmd" && std::env::var_os("COMSPEC").is_some())
}
/// Resolve the user's default shell from the configuration, environment
/// variables, then the shells found on PATH. Always returns a shell.
pub fn resolve_default(
configured: Option<&str>,
shell_env: Option<OsString>,
comspec: Option<OsString>,
) -> &'static ShellSpec {
if let Some(s) = configured.and_then(find) {
return s;
}
if let Some(s) = shell_env.and_then(|v| from_env_path(&v)) {
return s;
}
if let Some(s) = comspec.and_then(|v| from_env_path(&v)) {
return s;
}
let preferred: &[&str] = if cfg!(windows) {
&["pwsh", "powershell", "cmd"]
} else {
&["bash", "zsh", "fish", "sh"]
};
for name in preferred {
if let Some(s) = find(name) {
if probe_available(s) {
return s;
}
}
}
// Last resort: the platform's built-in shell.
find(if cfg!(windows) { "cmd" } else { "sh" }).expect("built-in shell must exist")
}
/// Extract a shell name from an environment value such as SHELL or COMSPEC.
fn from_env_path(value: &OsString) -> Option<&'static ShellSpec> {
let text = value.to_string_lossy();
let name = Path::new(text.as_ref())
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
find(&name)
}
/// The gateway's runtime state: which shell executes system commands now,
/// which one was detected as the user's default, and which are installed.
#[derive(Debug, Clone)]
pub struct ShellSession {
/// Shell executing system commands right now.
pub current: &'static ShellSpec,
/// The shell detected as the user's default at startup.
pub default: &'static ShellSpec,
/// Shells found on this machine.
pub available: Vec<&'static ShellSpec>,
}
impl ShellSession {
/// Build a session: detect the default shell and probe availability.
pub fn start(config: &Config) -> Self {
let default = resolve_default(
config.settings.default_shell.as_deref(),
std::env::var_os("SHELL"),
std::env::var_os("COMSPEC"),
);
let available = KNOWN_SHELLS
.iter()
.filter(|s| probe_available(s))
.collect();
Self {
current: default,
default,
available,
}
}
/// Switch the active shell. Fails for unknown or unavailable shells.
pub fn switch(&mut self, name: &str) -> Result<&'static ShellSpec> {
let spec = find(name).ok_or_else(|| anyhow!("unknown shell '{name}'"))?;
if !self.available.iter().any(|s| s.name == spec.name) {
return Err(anyhow!(
"shell '{}' is not available on this machine (available: {})",
spec.name,
self.available
.iter()
.map(|s| s.name)
.collect::<Vec<_>>()
.join(", ")
));
}
self.current = spec;
Ok(spec)
}
/// Whether the active shell is the user's default shell.
pub fn is_default(&self) -> bool {
self.current.name == self.default.name
}
/// Execute one system command line in the current shell with inherited
/// stdio, so interactive programs keep working. Returns the exit code.
pub fn run(&self, line: &str) -> Result<i32> {
let status = ProcessCommand::new(self.current.program)
.args(self.current.args)
.arg(line)
.status()
.map_err(|e| anyhow!("cannot run {}: {e}", self.current.program))?;
Ok(status.code().unwrap_or(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_resolves_aliases() {
assert_eq!(find("pwsh").unwrap().name, "pwsh");
assert_eq!(find("PowerShell7").unwrap().name, "pwsh");
assert_eq!(find("ps").unwrap().name, "powershell");
assert_eq!(find("cmd.exe").unwrap().name, "cmd");
assert_eq!(find("CMD").unwrap().name, "cmd");
assert_eq!(find("nushell").unwrap().name, "nu");
assert_eq!(find("dash").unwrap().name, "sh");
assert!(find("blorp").is_none());
}
#[test]
fn resolve_default_prefers_configured_shell() {
let s = resolve_default(Some("zsh"), None, None);
assert_eq!(s.name, "zsh");
}
#[test]
fn resolve_default_uses_environment_values() {
// SHELL wins over COMSPEC when the configured value is absent.
let s = resolve_default(
None,
Some(OsString::from("/usr/bin/bash")),
Some(OsString::from("C:/Windows/system32/cmd.exe")),
);
assert_eq!(s.name, "bash");
// COMSPEC alone is honored.
let s = resolve_default(
None,
None,
Some(OsString::from("C:/Windows/system32/cmd.exe")),
);
assert_eq!(s.name, "cmd");
// Unrecognized values fall through to a probe — result is still a
// known shell.
let s = resolve_default(None, Some(OsString::from("/usr/bin/weird")), None);
assert!(KNOWN_SHELLS.iter().any(|k| k.name == s.name));
}
#[test]
fn switch_validates_availability() {
let sh = find("sh").unwrap();
let bash = find("bash").unwrap();
let mut session = ShellSession {
current: sh,
default: sh,
available: vec![sh, bash],
};
assert_eq!(session.switch("bash").unwrap().name, "bash");
assert_eq!(session.current.name, "bash");
assert!(!session.is_default());
let err = session.switch("zsh").unwrap_err();
assert!(err.to_string().contains("not available"), "{err}");
let err = session.switch("blorp").unwrap_err();
assert!(err.to_string().contains("unknown shell"), "{err}");
}
}