Skip to main content

taskers_runtime/
shell.rs

1use std::{
2    collections::BTreeMap,
3    env, fs,
4    path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result};
8#[cfg(unix)]
9use libc::{self, passwd};
10#[cfg(unix)]
11use std::{ffi::CStr, os::unix::ffi::OsStringExt};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum ShellKind {
15    Bash,
16    Fish,
17    Zsh,
18    Other,
19}
20
21const INHERITED_TERMINAL_ENV_KEYS: &[&str] = &[
22    "TERM",
23    "TERMINFO",
24    "TERMINFO_DIRS",
25    "TERM_PROGRAM",
26    "TERM_PROGRAM_VERSION",
27    "COLORTERM",
28    "NO_COLOR",
29    "CLICOLOR",
30    "CLICOLOR_FORCE",
31    "KITTY_INSTALLATION_DIR",
32    "KITTY_LISTEN_ON",
33    "KITTY_PUBLIC_KEY",
34    "KITTY_WINDOW_ID",
35    "GHOSTTY_BIN_DIR",
36    "GHOSTTY_RESOURCES_DIR",
37    "GHOSTTY_SHELL_FEATURES",
38    "GHOSTTY_SHELL_INTEGRATION_XDG_DIR",
39];
40
41#[derive(Debug, Clone)]
42pub struct ShellIntegration {
43    root: PathBuf,
44    wrapper_path: PathBuf,
45    real_shell: PathBuf,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ShellLaunchSpec {
50    pub program: PathBuf,
51    pub args: Vec<String>,
52    pub env: BTreeMap<String, String>,
53}
54
55impl ShellLaunchSpec {
56    pub fn fallback() -> Self {
57        let program = default_shell_program();
58        let args = match shell_kind(&program) {
59            ShellKind::Fish => vec!["--interactive".into()],
60            ShellKind::Bash | ShellKind::Zsh => vec!["-i".into()],
61            ShellKind::Other => Vec::new(),
62        };
63        Self {
64            program,
65            args,
66            env: base_env(),
67        }
68    }
69
70    pub fn program_and_args(&self) -> Vec<String> {
71        let mut argv = Vec::with_capacity(self.args.len() + 1);
72        argv.push(self.program.display().to_string());
73        argv.extend(self.args.iter().cloned());
74        argv
75    }
76}
77
78impl ShellIntegration {
79    pub fn install(configured_shell: Option<&str>) -> Result<Self> {
80        let root = runtime_root();
81        let wrapper_path = root.join("taskers-shell-wrapper.sh");
82        let real_shell = resolve_shell_program(configured_shell)?;
83
84        write_asset(
85            &wrapper_path,
86            include_str!(concat!(
87                env!("CARGO_MANIFEST_DIR"),
88                "/assets/shell/taskers-shell-wrapper.sh"
89            )),
90            true,
91        )?;
92        write_asset(
93            &root.join("bash").join("taskers.bashrc"),
94            include_str!(concat!(
95                env!("CARGO_MANIFEST_DIR"),
96                "/assets/shell/bash/taskers.bashrc"
97            )),
98            false,
99        )?;
100        write_asset(
101            &root.join("taskers-hooks.bash"),
102            include_str!(concat!(
103                env!("CARGO_MANIFEST_DIR"),
104                "/assets/shell/taskers-hooks.bash"
105            )),
106            false,
107        )?;
108        write_asset(
109            &root.join("taskers-hooks.fish"),
110            include_str!(concat!(
111                env!("CARGO_MANIFEST_DIR"),
112                "/assets/shell/taskers-hooks.fish"
113            )),
114            false,
115        )?;
116        write_asset(
117            &root.join("taskers-agent-proxy.sh"),
118            include_str!(concat!(
119                env!("CARGO_MANIFEST_DIR"),
120                "/assets/shell/taskers-agent-proxy.sh"
121            )),
122            true,
123        )?;
124        install_agent_shims(&root)?;
125
126        Ok(Self {
127            root,
128            wrapper_path,
129            real_shell,
130        })
131    }
132
133    pub fn launch_spec(&self) -> ShellLaunchSpec {
134        let profile = std::env::var("TASKERS_SHELL_PROFILE").unwrap_or_else(|_| "default".into());
135        let integration_disabled = std::env::var_os("TASKERS_DISABLE_SHELL_INTEGRATION").is_some();
136
137        match shell_kind(&self.real_shell) {
138            ShellKind::Bash if !integration_disabled => {
139                let mut env = self.base_env();
140                env.insert(
141                    "TASKERS_REAL_SHELL".into(),
142                    self.real_shell.display().to_string(),
143                );
144                env.insert("TASKERS_SHELL_PROFILE".into(), profile);
145                if let Some(value) = std::env::var_os("TASKERS_USER_BASHRC") {
146                    env.insert(
147                        "TASKERS_USER_BASHRC".into(),
148                        value.to_string_lossy().into_owned(),
149                    );
150                }
151
152                ShellLaunchSpec {
153                    program: self.wrapper_path.clone(),
154                    args: Vec::new(),
155                    env,
156                }
157            }
158            ShellKind::Bash => ShellLaunchSpec {
159                program: self.real_shell.clone(),
160                args: vec!["--noprofile".into(), "--norc".into(), "-i".into()],
161                env: self.base_env(),
162            },
163            ShellKind::Fish if !integration_disabled => {
164                let env = self.base_env();
165
166                let mut args = Vec::new();
167                if profile == "clean" {
168                    args.push("--no-config".into());
169                }
170                args.push("--interactive".into());
171                args.push("--init-command".into());
172                args.push(fish_source_command());
173
174                ShellLaunchSpec {
175                    program: self.real_shell.clone(),
176                    args,
177                    env,
178                }
179            }
180            ShellKind::Fish => ShellLaunchSpec {
181                program: self.real_shell.clone(),
182                args: vec!["--no-config".into(), "--interactive".into()],
183                env: self.base_env(),
184            },
185            ShellKind::Zsh => {
186                let args = if profile == "clean" || integration_disabled {
187                    vec!["-d".into(), "-f".into(), "-i".into()]
188                } else {
189                    vec!["-i".into()]
190                };
191
192                ShellLaunchSpec {
193                    program: self.real_shell.clone(),
194                    args,
195                    env: self.base_env(),
196                }
197            }
198            ShellKind::Other => ShellLaunchSpec {
199                program: self.real_shell.clone(),
200                args: Vec::new(),
201                env: self.base_env(),
202            },
203        }
204    }
205
206    pub fn root(&self) -> &Path {
207        &self.root
208    }
209}
210
211impl ShellIntegration {
212    fn base_env(&self) -> BTreeMap<String, String> {
213        let mut env = base_env();
214        env.insert(
215            "TASKERS_SHELL_INTEGRATION_DIR".into(),
216            self.root.display().to_string(),
217        );
218        if let Some(path) = resolve_taskersctl_path() {
219            env.insert("TASKERS_CTL_PATH".into(), path.display().to_string());
220        }
221        let shim_dir = self.root.join("bin");
222        env.insert("PATH".into(), prepend_path_entry(&shim_dir));
223        env
224    }
225}
226
227pub fn install_shell_integration(configured_shell: Option<&str>) -> Result<ShellIntegration> {
228    ShellIntegration::install(configured_shell)
229}
230
231pub fn scrub_inherited_terminal_env() {
232    for key in INHERITED_TERMINAL_ENV_KEYS {
233        unsafe {
234            env::remove_var(key);
235        }
236    }
237}
238
239pub fn default_shell_program() -> PathBuf {
240    login_shell_from_passwd()
241        .or_else(shell_from_env)
242        .unwrap_or_else(|| PathBuf::from("/bin/sh"))
243}
244
245pub fn validate_shell_program(configured_shell: Option<&str>) -> Result<Option<PathBuf>> {
246    configured_shell
247        .and_then(normalize_shell_override)
248        .map(|value| resolve_shell_override(&value))
249        .transpose()
250}
251
252fn base_env() -> BTreeMap<String, String> {
253    let mut env = BTreeMap::new();
254    env.insert("TASKERS_EMBEDDED".into(), "1".into());
255    env.insert("TERM_PROGRAM".into(), "taskers".into());
256    env
257}
258
259fn install_agent_shims(root: &Path) -> Result<()> {
260    let shim_dir = root.join("bin");
261    fs::create_dir_all(&shim_dir)
262        .with_context(|| format!("failed to create {}", shim_dir.display()))?;
263    let proxy_path = root.join("taskers-agent-proxy.sh");
264
265    for name in ["codex", "claude", "claude-code", "opencode", "aider"] {
266        let shim_path = shim_dir.join(name);
267        if shim_path.symlink_metadata().is_ok() {
268            fs::remove_file(&shim_path)
269                .with_context(|| format!("failed to replace {}", shim_path.display()))?;
270        }
271
272        #[cfg(unix)]
273        std::os::unix::fs::symlink(&proxy_path, &shim_path).with_context(|| {
274            format!(
275                "failed to symlink {} -> {}",
276                shim_path.display(),
277                proxy_path.display()
278            )
279        })?;
280
281        #[cfg(not(unix))]
282        fs::copy(&proxy_path, &shim_path).with_context(|| {
283            format!(
284                "failed to copy {} -> {}",
285                proxy_path.display(),
286                shim_path.display()
287            )
288        })?;
289    }
290
291    Ok(())
292}
293
294fn prepend_path_entry(entry: &Path) -> String {
295    let mut parts = vec![entry.display().to_string()];
296    if let Some(path) = env::var_os("PATH") {
297        parts.extend(
298            env::split_paths(&path)
299                .filter(|candidate| candidate != entry)
300                .map(|candidate| candidate.display().to_string()),
301        );
302    }
303    parts.join(":")
304}
305
306fn runtime_root() -> PathBuf {
307    taskers_paths::default_shell_runtime_dir()
308}
309
310fn write_asset(path: &Path, content: &str, executable: bool) -> Result<()> {
311    if let Some(parent) = path.parent() {
312        fs::create_dir_all(parent)
313            .with_context(|| format!("failed to create {}", parent.display()))?;
314    }
315
316    fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?;
317
318    #[cfg(unix)]
319    if executable {
320        use std::os::unix::fs::PermissionsExt;
321
322        let mut permissions = fs::metadata(path)
323            .with_context(|| format!("failed to stat {}", path.display()))?
324            .permissions();
325        permissions.set_mode(0o755);
326        fs::set_permissions(path, permissions)
327            .with_context(|| format!("failed to chmod {}", path.display()))?;
328    }
329
330    Ok(())
331}
332
333fn resolve_taskersctl_path() -> Option<PathBuf> {
334    if let Some(path) = env::var_os("TASKERS_CTL_PATH")
335        .map(PathBuf::from)
336        .filter(|path| path.is_file())
337    {
338        return Some(path);
339    }
340
341    if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
342        for candidate in [
343            home.join(".cargo").join("bin").join("taskersctl"),
344            home.join(".local").join("bin").join("taskersctl"),
345        ] {
346            if candidate.is_file() {
347                return Some(candidate);
348            }
349        }
350    }
351
352    let path_var = env::var_os("PATH")?;
353    env::split_paths(&path_var)
354        .map(|entry| entry.join("taskersctl"))
355        .find(|candidate| candidate.is_file())
356}
357
358fn resolve_shell_program(configured_shell: Option<&str>) -> Result<PathBuf> {
359    if let Some(shell) = configured_shell.and_then(|value| normalize_shell_override(value)) {
360        return resolve_shell_override(&shell)
361            .with_context(|| format!("failed to resolve configured shell {shell}"));
362    }
363
364    Ok(default_shell_program())
365}
366
367fn shell_kind(path: &Path) -> ShellKind {
368    let name = path
369        .file_name()
370        .and_then(|value| value.to_str())
371        .unwrap_or_default()
372        .trim_start_matches('-');
373
374    match name {
375        "bash" => ShellKind::Bash,
376        "fish" => ShellKind::Fish,
377        "zsh" => ShellKind::Zsh,
378        _ => ShellKind::Other,
379    }
380}
381
382fn normalize_shell_override(value: &str) -> Option<String> {
383    let trimmed = value.trim();
384    if trimmed.is_empty() {
385        None
386    } else {
387        Some(trimmed.to_string())
388    }
389}
390
391fn resolve_shell_override(value: &str) -> Result<PathBuf> {
392    let expanded = expand_home_prefix(value);
393    let candidate = PathBuf::from(&expanded);
394    if expanded.contains('/') {
395        anyhow::ensure!(
396            candidate.is_file(),
397            "shell program {} does not exist",
398            candidate.display()
399        );
400        return Ok(candidate);
401    }
402
403    let path_var = env::var_os("PATH").unwrap_or_default();
404    let resolved = env::split_paths(&path_var)
405        .map(|entry| entry.join(&candidate))
406        .find(|entry| entry.is_file());
407    resolved.with_context(|| format!("shell program {value} was not found in PATH"))
408}
409
410fn expand_home_prefix(value: &str) -> String {
411    if value == "~" {
412        return env::var("HOME").unwrap_or_else(|_| value.to_string());
413    }
414
415    if let Some(suffix) = value.strip_prefix("~/") {
416        if let Some(home) = env::var_os("HOME") {
417            return PathBuf::from(home).join(suffix).display().to_string();
418        }
419    }
420
421    value.to_string()
422}
423
424fn shell_from_env() -> Option<PathBuf> {
425    env::var_os("SHELL")
426        .map(PathBuf::from)
427        .filter(|path| !path.as_os_str().is_empty())
428}
429
430#[cfg(unix)]
431fn login_shell_from_passwd() -> Option<PathBuf> {
432    let uid = unsafe { libc::geteuid() };
433    let mut pwd = std::mem::MaybeUninit::<passwd>::uninit();
434    let mut result = std::ptr::null_mut::<passwd>();
435    let mut buffer = vec![0u8; passwd_buffer_size()];
436
437    let status = unsafe {
438        libc::getpwuid_r(
439            uid,
440            pwd.as_mut_ptr(),
441            buffer.as_mut_ptr().cast(),
442            buffer.len(),
443            &mut result,
444        )
445    };
446    if status != 0 || result.is_null() {
447        return None;
448    }
449
450    let pwd = unsafe { pwd.assume_init() };
451    if pwd.pw_shell.is_null() {
452        return None;
453    }
454
455    let shell = unsafe { CStr::from_ptr(pwd.pw_shell) }.to_bytes().to_vec();
456    if shell.is_empty() {
457        return None;
458    }
459
460    Some(PathBuf::from(std::ffi::OsString::from_vec(shell)))
461}
462
463#[cfg(not(unix))]
464fn login_shell_from_passwd() -> Option<PathBuf> {
465    None
466}
467
468#[cfg(unix)]
469fn passwd_buffer_size() -> usize {
470    let size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
471    if size <= 0 { 4096 } else { size as usize }
472}
473
474#[cfg(not(unix))]
475fn passwd_buffer_size() -> usize {
476    4096
477}
478
479fn fish_source_command() -> String {
480    r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#.into()
481}
482
483#[cfg(test)]
484mod tests {
485    use super::{
486        INHERITED_TERMINAL_ENV_KEYS, expand_home_prefix, fish_source_command,
487        normalize_shell_override,
488    };
489
490    #[test]
491    fn shell_override_normalizes_blank_values() {
492        assert_eq!(normalize_shell_override(""), None);
493        assert_eq!(normalize_shell_override("   "), None);
494        assert_eq!(
495            normalize_shell_override(" /usr/bin/fish "),
496            Some("/usr/bin/fish".into())
497        );
498    }
499
500    #[test]
501    fn fish_source_command_uses_runtime_env_path() {
502        assert_eq!(
503            fish_source_command(),
504            r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#
505        );
506    }
507
508    #[test]
509    fn home_prefix_expansion_without_home_keeps_original_shape() {
510        let original = "~/bin/fish";
511        let expanded = expand_home_prefix(original);
512        if std::env::var_os("HOME").is_some() {
513            assert_ne!(expanded, original);
514        } else {
515            assert_eq!(expanded, original);
516        }
517    }
518
519    #[test]
520    fn inherited_terminal_env_keys_cover_color_and_terminfo_leaks() {
521        for key in ["NO_COLOR", "TERMINFO", "TERMINFO_DIRS", "TERM_PROGRAM"] {
522            assert!(
523                INHERITED_TERMINAL_ENV_KEYS.contains(&key),
524                "expected {key} to be scrubbed from inherited terminal env"
525            );
526        }
527    }
528}