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