Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions crates/tirith/assets/hooks/zshenv-guard.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ if [[ -n "${ZSH_EXECUTION_STRING:-}" \
&& "${TIRITH_ZSHENV_SKIP:-}" != "1" \
&& -z "${VSCODE_RESOLVING_ENVIRONMENT:-}" ]]; then

# __TIRITH_BIN__ is replaced at setup time by resolve_tirith_bin()
_tirith_bin="${TIRITH_BIN:-__TIRITH_BIN__}"
# __TIRITH_BIN__ is replaced at setup time by zshenv-specific path resolution.
_tirith_bin="${TIRITH_BIN:-}"
if [[ -z "$_tirith_bin" ]]; then
_tirith_bin=__TIRITH_BIN__
fi

if [[ ! -x "$(command -v "$_tirith_bin" 2>/dev/null)" ]]; then
echo "tirith: $_tirith_bin not found — command blocked for safety" >&2
Expand Down
164 changes: 164 additions & 0 deletions crates/tirith/src/cli/setup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub use self::run_impl::run;
mod run_impl {
use super::fs_helpers;
use etcetera::BaseStrategy;
#[cfg(unix)]
use std::path::Path;
use std::path::PathBuf;

/// All tools recognized by `tirith setup`.
Expand Down Expand Up @@ -254,6 +256,109 @@ mod run_impl {
}
}

/// Resolve a tirith path suitable for ~/.zshenv.
///
/// Unlike interactive shell profiles and MCP configs, `.zshenv` runs before
/// normal PATH setup. Prefer a stable executable path so non-interactive
/// `zsh -lc ...` checks do not depend on `.zprofile` or `.zshrc`.
#[cfg(unix)]
pub(super) fn resolve_tirith_bin_for_zshenv(
tirith_bin: &str,
dry_run: bool,
) -> Result<String, String> {
choose_zshenv_tirith_bin(
find_executable_on_path("tirith"),
current_tirith_exe(),
tirith_bin,
dry_run,
)
}

#[cfg(unix)]
fn choose_zshenv_tirith_bin(
path_candidate: Option<PathBuf>,
current_exe: Option<PathBuf>,
tirith_bin: &str,
dry_run: bool,
) -> Result<String, String> {
if let Some(path) = path_candidate {
if is_script_wrapper(&path) {
if let Some(exe) = current_exe {
return Ok(exe.display().to_string());
}
}
return Ok(path.display().to_string());
}

if let Some(exe) = current_exe {
return Ok(exe.display().to_string());
}

if Path::new(tirith_bin).is_absolute() {
return Ok(tirith_bin.to_string());
}

if dry_run {
eprintln!(
"tirith: WARNING: tirith not found — previewing zshenv guard with portable name 'tirith' (actual setup would fail)"
);
Ok("tirith".into())
} else {
Err(
"tirith binary not found — ensure tirith is installed and on PATH before installing zshenv guard"
.into(),
)
}
}

#[cfg(unix)]
fn current_tirith_exe() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
if exe.file_name()? == "tirith" {
Some(exe)
} else {
None
}
}

#[cfg(unix)]
fn find_executable_on_path(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if is_executable_file(&candidate) {
if candidate.is_absolute() {
return Some(candidate);
}
if let Ok(abs) = candidate.canonicalize() {
return Some(abs);
}
}
}
None
}

#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;

let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}

#[cfg(unix)]
fn is_script_wrapper(path: &Path) -> bool {
use std::io::Read;

let Ok(mut file) = std::fs::File::open(path) else {
return false;
};
let mut bytes = [0u8; 2];
file.read_exact(&mut bytes).is_ok() && bytes == *b"#!"
}

/// Check that a binary is available on PATH.
/// In dry-run mode, warn but don't fail.
fn check_binary_on_path(name: &str, dry_run: bool) -> Result<(), String> {
Expand Down Expand Up @@ -386,6 +491,19 @@ mod run_impl {
mod tests {
use super::*;

#[cfg(unix)]
fn write_executable(path: &Path, content: &str) {
use std::os::unix::fs::PermissionsExt;

if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}

#[test]
fn resolve_scope_rejects_user_for_copilot_cli() {
let result = resolve_scope("copilot-cli", Some("user"));
Expand Down Expand Up @@ -415,5 +533,51 @@ mod run_impl {
);
assert_eq!(resolve_scope("kiro", Some("user")).unwrap(), Scope::User);
}

#[cfg(unix)]
#[test]
fn zshenv_resolver_prefers_executable_path_over_portable_name() {
let dir = tempfile::tempdir().unwrap();
let tirith = dir.path().join("tirith");
write_executable(&tirith, "");

let resolved =
choose_zshenv_tirith_bin(Some(tirith.clone()), None, "tirith", false).unwrap();

assert_eq!(resolved, tirith.display().to_string());
}

#[cfg(unix)]
#[test]
fn zshenv_resolver_uses_current_exe_when_path_entry_is_script_wrapper() {
let dir = tempfile::tempdir().unwrap();
let wrapper = dir.path().join("tirith");
let native = dir.path().join("native").join("tirith");
write_executable(&wrapper, "#!/usr/bin/env node\n");
write_executable(&native, "");

let resolved =
choose_zshenv_tirith_bin(Some(wrapper), Some(native.clone()), "tirith", false)
.unwrap();

assert_eq!(resolved, native.display().to_string());
}

#[cfg(unix)]
#[test]
fn zshenv_resolver_keeps_absolute_fallback() {
let resolved =
choose_zshenv_tirith_bin(None, None, "/opt/custom/bin/tirith", false).unwrap();

assert_eq!(resolved, "/opt/custom/bin/tirith");
}

#[cfg(unix)]
#[test]
fn zshenv_resolver_allows_portable_name_in_dry_run() {
let resolved = choose_zshenv_tirith_bin(None, None, "tirith", true).unwrap();

assert_eq!(resolved, "tirith");
}
}
}
40 changes: 16 additions & 24 deletions crates/tirith/src/cli/setup/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ use super::run_impl::{copy_gateway_config, Scope, SetupOpts};
use super::zshenv;
use serde_json::json;

#[cfg(unix)]
fn offer_zshenv_guard_for_opts(opts: &SetupOpts) -> Result<(), String> {
let zshenv_tirith_bin =
super::run_impl::resolve_tirith_bin_for_zshenv(&opts.tirith_bin, opts.dry_run)?;
zshenv::offer_zshenv_guard(
opts.install_zshenv,
opts.force,
opts.dry_run,
&zshenv_tirith_bin,
)
}

pub fn setup_claude_code(opts: &SetupOpts) -> Result<(), String> {
let home = home::home_dir().ok_or_else(|| "could not determine home directory".to_string())?;
let target = match opts.scope {
Expand Down Expand Up @@ -218,12 +230,7 @@ pub fn setup_codex(opts: &SetupOpts) -> Result<(), String> {
}

#[cfg(unix)]
zshenv::offer_zshenv_guard(
opts.install_zshenv,
opts.force,
opts.dry_run,
&opts.tirith_bin,
)?;
offer_zshenv_guard_for_opts(opts)?;

eprintln!();
eprintln!("tirith: Codex setup complete");
Expand Down Expand Up @@ -312,12 +319,7 @@ pub fn setup_cursor(opts: &SetupOpts) -> Result<(), String> {
}

#[cfg(unix)]
zshenv::offer_zshenv_guard(
opts.install_zshenv,
opts.force,
opts.dry_run,
&opts.tirith_bin,
)?;
offer_zshenv_guard_for_opts(opts)?;

eprintln!();
eprintln!("tirith: Cursor setup complete");
Expand Down Expand Up @@ -383,12 +385,7 @@ pub fn setup_vscode(opts: &SetupOpts) -> Result<(), String> {
}

#[cfg(unix)]
zshenv::offer_zshenv_guard(
opts.install_zshenv,
opts.force,
opts.dry_run,
&opts.tirith_bin,
)?;
offer_zshenv_guard_for_opts(opts)?;

eprintln!();
eprintln!("tirith: VS Code setup complete");
Expand Down Expand Up @@ -649,12 +646,7 @@ pub fn setup_windsurf(opts: &SetupOpts) -> Result<(), String> {
}

#[cfg(unix)]
zshenv::offer_zshenv_guard(
opts.install_zshenv,
opts.force,
opts.dry_run,
&opts.tirith_bin,
)?;
offer_zshenv_guard_for_opts(opts)?;

eprintln!();
eprintln!("tirith: Windsurf setup complete");
Expand Down
48 changes: 47 additions & 1 deletion crates/tirith/src/cli/setup/zshenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ pub fn offer_zshenv_guard(
dry_run: bool,
tirith_bin: &str,
) -> Result<(), String> {
let guard_content = crate::assets::ZSHENV_GUARD.replace("__TIRITH_BIN__", tirith_bin);
let quoted_tirith_bin = super::shell_profile::shell_quote(tirith_bin, "zsh");
let guard_content = crate::assets::ZSHENV_GUARD.replace("__TIRITH_BIN__", &quoted_tirith_bin);
let managed_block = format!("{BEGIN_MARKER}\n{guard_content}\n{END_MARKER}\n");

if !install {
Expand Down Expand Up @@ -313,6 +314,15 @@ mod tests {
use super::*;
use crate::cli::test_harness::with_fake_env;

fn write_executable(path: &std::path::Path, content: &str) {
use std::os::unix::fs::PermissionsExt;

std::fs::write(path, content).unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}

fn zsh_available() -> bool {
std::process::Command::new("zsh")
.arg("--version")
Expand Down Expand Up @@ -466,6 +476,20 @@ mod tests {
});
}

#[test]
fn tirith_bin_placeholder_is_quoted_for_zsh() {
if !zsh_available() {
return;
}
with_fake_home(|home| {
offer_zshenv_guard(true, false, false, "/opt/it's tirith/bin/tirith").unwrap();
let content = std::fs::read_to_string(home.join(".zshenv")).unwrap();

assert!(content.contains("_tirith_bin='/opt/it'\\''s tirith/bin/tirith'"));
assert!(!content.contains("__TIRITH_BIN__"));
});
}

/// Helper: write a .zshenv with the guard plus a trailing export,
/// then run `zsh -c 'echo $POST_GUARD'` with the given extra env
/// vars. Returns (stdout, exit_code).
Expand Down Expand Up @@ -495,6 +519,7 @@ mod tests {
.env("ZDOTDIR", home)
.env("HOME", home)
.envs(extra_env.iter().copied())
.env_remove("TIRITH_BIN")
.output()
.expect("failed to spawn zsh");

Expand Down Expand Up @@ -552,5 +577,26 @@ mod tests {
assert_eq!(code, 1, "guard should exit 1 when tirith binary not found");
});
}

#[test]
fn guard_uses_baked_absolute_tirith_when_path_lacks_it() {
if !zsh_available() {
return;
}
with_fake_home(|home| {
let dir = tempfile::tempdir().unwrap();
let tirith = dir.path().join("tirith");
write_executable(&tirith, "#!/bin/sh\nexit 0\n");

let (stdout, code) = run_guard_scenario(
home,
&tirith.display().to_string(),
&[("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")],
);

assert_eq!(code, 0, "absolute tirith path should not need PATH lookup");
assert_eq!(stdout, "POST_GUARD=loaded");
});
}
}
}