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}