use std::{
env,
ffi::OsString,
path::{Path, PathBuf},
};
#[cfg(unix)]
use nix::unistd::{Uid, User};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ShellType {
Zsh,
Bash,
PowerShell,
Sh,
Cmd,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Shell {
shell_type: ShellType,
path: PathBuf,
}
impl Shell {
pub(super) fn path(&self) -> &Path {
&self.path
}
pub(super) const fn name(&self) -> &'static str {
match self.shell_type {
ShellType::Zsh => "zsh",
ShellType::Bash => "bash",
ShellType::PowerShell => "powershell",
ShellType::Sh => "sh",
ShellType::Cmd => "cmd",
}
}
pub(super) fn args(&self, script: &str, login: bool) -> Vec<String> {
match self.shell_type {
ShellType::Zsh | ShellType::Bash | ShellType::Sh => vec![
if login { "-lc" } else { "-c" }.to_owned(),
script.to_owned(),
],
ShellType::PowerShell => {
let mut args = Vec::with_capacity(3);
if !login {
args.push("-NoProfile".to_owned());
}
args.push("-Command".to_owned());
args.push(script.to_owned());
args
}
ShellType::Cmd => vec!["/c".to_owned(), script.to_owned()],
}
}
}
fn detect_shell_type(shell_path: &Path) -> Option<ShellType> {
match shell_path.as_os_str().to_str() {
Some("zsh") => Some(ShellType::Zsh),
Some("bash") => Some(ShellType::Bash),
Some("pwsh" | "powershell") => Some(ShellType::PowerShell),
Some("sh") => Some(ShellType::Sh),
Some("cmd") => Some(ShellType::Cmd),
_ => {
let shell_name = shell_path.file_stem()?;
let shell_name = Path::new(shell_name);
(shell_name != shell_path)
.then(|| detect_shell_type(shell_name))
.flatten()
}
}
}
#[cfg(unix)]
fn user_shell_path() -> Option<PathBuf> {
User::from_uid(Uid::current())
.ok()
.flatten()
.map(|user| user.shell)
}
#[cfg(windows)]
fn user_shell_path() -> Option<PathBuf> {
env::var_os("COMSPEC").map(PathBuf::from)
}
#[cfg(not(any(unix, windows)))]
fn user_shell_path() -> Option<PathBuf> {
None
}
fn file_exists(path: &Path) -> Option<PathBuf> {
path.metadata()
.is_ok_and(|metadata| metadata.is_file())
.then(|| path.to_path_buf())
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
path.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
path.metadata().is_ok_and(|metadata| metadata.is_file())
}
fn executable_names(binary: &str) -> Vec<OsString> {
#[cfg(windows)]
{
if Path::new(binary).extension().is_some() {
return vec![binary.into()];
}
let extensions = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_owned());
return extensions
.split(';')
.filter(|extension| !extension.is_empty())
.map(|extension| format!("{binary}{extension}").into())
.collect();
}
#[cfg(not(windows))]
{
vec![binary.into()]
}
}
fn find_in_path(binary: &str) -> Option<PathBuf> {
let names = executable_names(binary);
env::var_os("PATH")
.into_iter()
.flat_map(|path| env::split_paths(&path).collect::<Vec<_>>())
.flat_map(|directory| names.iter().map(move |name| directory.join(name)))
.find(|candidate| is_executable(candidate))
}
fn shell_path(
shell_type: ShellType,
provided_path: Option<&Path>,
binary_name: &str,
fallback_paths: &[&str],
) -> Option<PathBuf> {
if let Some(path) = provided_path.and_then(file_exists) {
return Some(path);
}
let user_shell = user_shell_path()
.filter(|path| detect_shell_type(path) == Some(shell_type))
.and_then(|path| file_exists(&path));
if let Some(path) = user_shell {
return Some(path);
}
find_in_path(binary_name).or_else(|| {
fallback_paths
.iter()
.find_map(|path| file_exists(Path::new(path)))
})
}
fn shell(shell_type: ShellType, provided_path: Option<&Path>) -> Option<Shell> {
let path = match shell_type {
ShellType::Zsh => shell_path(shell_type, provided_path, "zsh", &["/bin/zsh"]),
ShellType::Bash => shell_path(
shell_type,
provided_path,
"bash",
&["/bin/bash", "/usr/bin/bash"],
),
ShellType::PowerShell => {
shell_path(shell_type, provided_path, "pwsh", &["/usr/local/bin/pwsh"])
.or_else(|| shell_path(shell_type, provided_path, "powershell", &[]))
}
ShellType::Sh => shell_path(shell_type, provided_path, "sh", &["/bin/sh"]),
ShellType::Cmd => shell_path(shell_type, provided_path, "cmd", &[]),
}?;
Some(Shell { shell_type, path })
}
fn ultimate_fallback_shell() -> Shell {
#[cfg(windows)]
{
return Shell {
shell_type: ShellType::Cmd,
path: env::var_os("COMSPEC").map_or_else(|| PathBuf::from("cmd.exe"), PathBuf::from),
};
}
#[cfg(not(windows))]
{
Shell {
shell_type: ShellType::Sh,
path: PathBuf::from("/bin/sh"),
}
}
}
pub(super) fn get_shell_by_model_provided_path(path: &str) -> Shell {
let path = Path::new(path);
detect_shell_type(path)
.and_then(|shell_type| shell(shell_type, Some(path)))
.unwrap_or_else(ultimate_fallback_shell)
}
pub(super) fn default_user_shell() -> Shell {
let user_shell_path = user_shell_path();
default_user_shell_from_path(user_shell_path.as_deref())
}
fn default_user_shell_from_path(user_shell_path: Option<&Path>) -> Shell {
let user_default_shell = user_shell_path
.and_then(detect_shell_type)
.and_then(|shell_type| shell(shell_type, None));
#[cfg(windows)]
let shell_with_fallback = user_default_shell
.or_else(|| shell(ShellType::PowerShell, None))
.or_else(|| shell(ShellType::Cmd, None));
#[cfg(not(windows))]
let shell_with_fallback = if cfg!(target_os = "macos") {
user_default_shell
.or_else(|| shell(ShellType::Zsh, None))
.or_else(|| shell(ShellType::Bash, None))
} else {
user_default_shell
.or_else(|| shell(ShellType::Bash, None))
.or_else(|| shell(ShellType::Zsh, None))
};
shell_with_fallback.unwrap_or_else(ultimate_fallback_shell)
}
#[cfg(test)]
mod tests {
use super::{
ShellType, default_user_shell_from_path, detect_shell_type,
get_shell_by_model_provided_path,
};
use std::path::Path;
#[test]
fn detects_codex_shell_names_and_paths() {
assert_eq!(detect_shell_type(Path::new("zsh")), Some(ShellType::Zsh));
assert_eq!(
detect_shell_type(Path::new("/usr/bin/bash")),
Some(ShellType::Bash)
);
assert_eq!(
detect_shell_type(Path::new("pwsh.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(detect_shell_type(Path::new("fish")), None);
}
#[test]
fn unavailable_user_shell_uses_codex_platform_fallbacks() {
let shell = default_user_shell_from_path(Some(Path::new("/missing/fish")));
if cfg!(windows) {
assert!(matches!(
shell.shell_type,
ShellType::PowerShell | ShellType::Cmd
));
} else if cfg!(target_os = "macos") && Path::new("/bin/zsh").is_file() {
assert_eq!(shell.shell_type, ShellType::Zsh);
} else if Path::new("/bin/bash").is_file() {
assert_eq!(shell.shell_type, ShellType::Bash);
} else {
assert_eq!(shell.shell_type, ShellType::Sh);
}
}
#[test]
fn unknown_model_shell_uses_platform_fallback() {
let shell = get_shell_by_model_provided_path("/definitely/missing/fish");
if cfg!(windows) {
assert_eq!(shell.shell_type, ShellType::Cmd);
} else {
assert_eq!(shell.shell_type, ShellType::Sh);
assert_eq!(shell.path, Path::new("/bin/sh"));
}
}
}