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}