#[cfg(unix)]
use libc;
use std::fs;
use std::path::PathBuf;
#[cfg(unix)]
use std::process::Command;
use crate::assets;
fn posix_single_quote(path: &str) -> String {
format!("'{}'", path.replace('\'', "'\\''"))
}
fn powershell_single_quote(path: &str) -> String {
format!("'{}'", path.replace('\'', "''"))
}
fn check_path_shadow() -> Option<String> {
let shadows = super::find_shadow_binaries();
if shadows.is_empty() {
return None;
}
let our_exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "unknown".into());
Some(format!(
"tirith: WARNING: '{}' shadows this binary ({})\n\
tirith: This may be a different package (e.g. pip-installed).\n\
tirith: Run 'which -a tirith' to inspect, and remove the conflicting binary.",
shadows[0], our_exe,
))
}
pub fn run(shell: Option<&str>) -> i32 {
if let Some(warning) = check_path_shadow() {
eprintln!("{warning}");
}
let shell = shell.unwrap_or_else(|| detect_shell());
let hook_dir = find_hook_dir();
match shell {
"zsh" => {
if let Some(dir) = &hook_dir {
println!(
"source {}",
posix_single_quote(&dir.join("lib/zsh-hook.zsh").display().to_string())
);
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
0
}
"bash" => {
if let Some(dir) = &hook_dir {
println!(
"source {}",
posix_single_quote(&dir.join("lib/bash-hook.bash").display().to_string())
);
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
0
}
"fish" => {
if let Some(dir) = &hook_dir {
println!(
"source {}",
posix_single_quote(&dir.join("lib/fish-hook.fish").display().to_string())
);
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
0
}
"powershell" | "pwsh" => {
if let Some(dir) = &hook_dir {
println!(
". {}",
powershell_single_quote(
&dir.join("lib/powershell-hook.ps1").display().to_string()
)
);
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
0
}
"nushell" | "nu" => {
if let Some(dir) = &hook_dir {
println!(
"source {}",
posix_single_quote(&dir.join("lib/nushell-hook.nu").display().to_string())
);
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
0
}
_ => {
eprintln!("tirith: unsupported shell '{shell}'");
eprintln!("Supported: zsh, bash, fish, powershell, nushell");
eprintln!(" try: tirith init --shell zsh");
1
}
}
}
pub(crate) fn detect_shell() -> &'static str {
if let Some(shell) = detect_shell_from_parent() {
return shell;
}
if let Ok(shell) = std::env::var("SHELL") {
if let Some(shell) = normalize_shell_name(&shell) {
return shell;
}
}
#[cfg(windows)]
return "powershell";
#[cfg(not(windows))]
"bash"
}
fn normalize_shell_name(name: &str) -> Option<&'static str> {
let name = name.trim();
if name.is_empty() {
return None;
}
let base = name
.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.trim_start_matches('-')
.to_ascii_lowercase();
if base.contains("zsh") {
Some("zsh")
} else if base.contains("bash") {
Some("bash")
} else if base.contains("fish") {
Some("fish")
} else if base.contains("pwsh") || base.contains("powershell") {
Some("powershell")
} else if base == "nu" || base == "nu.exe" || base.contains("nushell") {
Some("nushell")
} else {
None
}
}
#[cfg(unix)]
fn detect_shell_from_parent() -> Option<&'static str> {
let mut pid = unsafe { libc::getppid() };
for _ in 0..8 {
if pid <= 1 {
return None;
}
let (name, parent_pid) = read_process(pid)?;
if let Some(shell) = normalize_shell_name(&name) {
return Some(shell);
}
if parent_pid == pid {
break;
}
pid = parent_pid;
}
None
}
#[cfg(unix)]
fn read_process(pid: libc::pid_t) -> Option<(String, libc::pid_t)> {
let output = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm=", "-o", "ppid="])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let line = String::from_utf8_lossy(&output.stdout);
let mut parts = line.split_whitespace();
let name = parts.next()?.to_string();
let ppid = parts.next()?.parse::<libc::pid_t>().ok()?;
Some((name, ppid))
}
#[cfg(not(unix))]
fn detect_shell_from_parent() -> Option<&'static str> {
None
}
pub fn find_hook_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("TIRITH_SHELL_DIR") {
let p = PathBuf::from(&dir);
if p.join("lib").exists() {
return Some(p);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
let brew_dir = bin_dir.join("../share/tirith/shell");
if brew_dir.join("lib").exists() {
return Some(brew_dir.canonicalize().unwrap_or(brew_dir));
}
#[cfg(unix)]
{
let sys_dir = PathBuf::from("/usr/share/tirith/shell");
if sys_dir.join("lib").exists() {
return Some(sys_dir);
}
}
let cargo_dir = bin_dir.join("../shell");
if cargo_dir.join("lib").exists() {
return Some(cargo_dir.canonicalize().unwrap_or(cargo_dir));
}
let dev_dir = bin_dir.join("../../shell");
if dev_dir.join("lib").exists() {
return Some(dev_dir.canonicalize().unwrap_or(dev_dir));
}
}
}
materialize_hooks()
}
pub fn find_hook_dir_readonly() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("TIRITH_SHELL_DIR") {
let p = PathBuf::from(&dir);
if p.join("lib").exists() {
return Some(p);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
let brew_dir = bin_dir.join("../share/tirith/shell");
if brew_dir.join("lib").exists() {
return Some(brew_dir.canonicalize().unwrap_or(brew_dir));
}
#[cfg(unix)]
{
let sys_dir = PathBuf::from("/usr/share/tirith/shell");
if sys_dir.join("lib").exists() {
return Some(sys_dir);
}
}
let cargo_dir = bin_dir.join("../shell");
if cargo_dir.join("lib").exists() {
return Some(cargo_dir.canonicalize().unwrap_or(cargo_dir));
}
let dev_dir = bin_dir.join("../../shell");
if dev_dir.join("lib").exists() {
return Some(dev_dir.canonicalize().unwrap_or(dev_dir));
}
}
}
if let Some(data_dir) = tirith_core::policy::data_dir() {
let shell_dir = data_dir.join("shell");
if shell_dir.join("lib").exists() {
return Some(shell_dir);
}
}
None
}
fn materialize_hooks() -> Option<PathBuf> {
let data_dir = tirith_core::policy::data_dir()?;
let shell_dir = data_dir.join("shell");
let lib_dir = shell_dir.join("lib");
let version_path = shell_dir.join(".hooks-version");
let current_version = env!("CARGO_PKG_VERSION");
let required_files = [
shell_dir.join("tirith.sh"),
lib_dir.join("zsh-hook.zsh"),
lib_dir.join("bash-hook.bash"),
lib_dir.join("fish-hook.fish"),
lib_dir.join("powershell-hook.ps1"),
lib_dir.join("nushell-hook.nu"),
];
let version_matches = fs::read_to_string(&version_path)
.ok()
.map(|v| v.trim() == current_version)
.unwrap_or(false);
let needs_write = !required_files.iter().all(|p| p.exists()) || !version_matches;
if needs_write {
if let Err(e) = fs::create_dir_all(&lib_dir) {
eprintln!(
"tirith: failed to create hook directory {}: {e}",
lib_dir.display()
);
return None;
}
let hook_files: Vec<(PathBuf, &str)> = vec![
(shell_dir.join("tirith.sh"), assets::TIRITH_SH),
(lib_dir.join("zsh-hook.zsh"), assets::ZSH_HOOK),
(lib_dir.join("bash-hook.bash"), assets::BASH_HOOK),
(lib_dir.join("fish-hook.fish"), assets::FISH_HOOK),
(lib_dir.join("powershell-hook.ps1"), assets::POWERSHELL_HOOK),
(lib_dir.join("nushell-hook.nu"), assets::NUSHELL_HOOK),
];
for (path, content) in &hook_files {
if let Err(e) = fs::write(path, content) {
eprintln!("tirith: failed to write hook {}: {e}", path.display());
return None;
}
}
if let Err(e) = fs::write(&version_path, format!("{current_version}\n")) {
eprintln!("tirith: failed to write hook version file: {e}");
return None;
}
eprintln!(
"tirith: materialized shell hooks to {}",
shell_dir.display()
);
}
Some(shell_dir)
}
#[cfg(test)]
mod tests {
use super::{normalize_shell_name, posix_single_quote, powershell_single_quote};
#[test]
fn normalize_shell_name_from_paths_and_login_shells() {
assert_eq!(normalize_shell_name("/bin/bash"), Some("bash"));
assert_eq!(normalize_shell_name("/opt/homebrew/bin/fish"), Some("fish"));
assert_eq!(normalize_shell_name("-zsh"), Some("zsh"));
}
#[test]
fn normalize_shell_name_supports_case_insensitive_names() {
assert_eq!(normalize_shell_name("BASH"), Some("bash"));
assert_eq!(normalize_shell_name("PwSh"), Some("powershell"));
assert_eq!(normalize_shell_name("PowerShell"), Some("powershell"));
}
#[test]
fn normalize_shell_name_supports_nushell() {
assert_eq!(normalize_shell_name("nu"), Some("nushell"));
assert_eq!(normalize_shell_name("nu.exe"), Some("nushell"));
assert_eq!(normalize_shell_name("nushell"), Some("nushell"));
assert_eq!(normalize_shell_name("nushell.exe"), Some("nushell"));
assert_eq!(normalize_shell_name("/usr/bin/nu"), Some("nushell"));
assert_eq!(
normalize_shell_name("C:\\Program Files\\nu.exe"),
Some("nushell")
);
}
#[test]
fn normalize_shell_name_no_false_positive_on_gnu() {
assert_eq!(normalize_shell_name("gnu"), None);
}
#[test]
fn normalize_shell_name_rejects_unknown_values() {
assert_eq!(normalize_shell_name(""), None);
assert_eq!(normalize_shell_name("python"), None);
}
#[test]
fn quote_helpers_escape_shell_metacharacters() {
assert_eq!(
posix_single_quote("/tmp/hook' > file"),
"'/tmp/hook'\\'' > file'"
);
assert_eq!(
powershell_single_quote("C:\\temp\\it's.ps1"),
"'C:\\temp\\it''s.ps1'"
);
}
}