diff options
| author | Ellie Huxtable <ellie@atuin.sh> | 2026-03-04 00:47:17 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-04 00:47:17 +0100 |
| commit | 0685cb414d2d163ef84106b37029d24aa154694c (patch) | |
| tree | 3b4f68ef5a6b75b647b8b5e44dd509bd99477edb /crates/atuin-shell/src/main.rs | |
| parent | fix: clear script database before rebuild to prevent unique constraint violat... (diff) | |
| download | atuin-0685cb414d2d163ef84106b37029d24aa154694c.zip | |
feat: initial draft of atuin-shell (#3206)
<!-- Thank you for making a PR! Bug fixes are always welcome, but if
you're adding a new feature or changing an existing one, we'd really
appreciate if you open an issue, post on the forum, or drop in on
Discord -->
## Checks
- [ ] I am happy for maintainers to push small adjustments to this PR,
to speed up the review cycle
- [ ] I have checked that there are no existing pull requests for the
same thing
---------
Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'crates/atuin-shell/src/main.rs')
| -rw-r--r-- | crates/atuin-shell/src/main.rs | 342 |
1 files changed, 342 insertions, 0 deletions
diff --git a/crates/atuin-shell/src/main.rs b/crates/atuin-shell/src/main.rs new file mode 100644 index 00000000..337237de --- /dev/null +++ b/crates/atuin-shell/src/main.rs @@ -0,0 +1,342 @@ +mod osc133; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +#[derive(Parser, Debug)] +#[command(infer_subcommands = true)] +struct Cli { + #[command(subcommand)] + command: Option<Cmd>, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Print shell code to initialize atuin-shell on shell startup + Init(Init), +} + +#[derive(Args, Debug)] +struct Init { + /// Shell to generate init for. If omitted, attempt auto-detection + #[arg(value_enum)] + shell: Option<Shell>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +#[value(rename_all = "lower")] +#[allow(clippy::enum_variant_names, clippy::doc_markdown)] +enum Shell { + /// Zsh setup + Zsh, + /// Bash setup + Bash, + /// Fish setup + Fish, +} + +impl Shell { + fn as_str(self) -> &'static str { + match self { + Self::Bash => "bash", + Self::Zsh => "zsh", + Self::Fish => "fish", + } + } +} + +impl Init { + fn run(self) -> Result<(), String> { + let shell = detect_shell(self.shell)?; + let script = render_init(shell); + print!("{script}"); + Ok(()) + } +} + +fn detect_shell(cli_shell: Option<Shell>) -> Result<Shell, String> { + if let Some(shell) = cli_shell { + return Ok(shell); + } + + if let Ok(shell) = std::env::var("ATUIN_SHELL") + && let Some(shell) = shell_from_name(&shell) + { + return Ok(shell); + } + + if let Ok(shell) = std::env::var("SHELL") + && let Some(shell) = shell_from_name(&shell) + { + return Ok(shell); + } + + Err( + "could not detect a supported shell. Please specify one explicitly: bash, zsh, or fish" + .to_string(), + ) +} + +fn shell_from_name(name: &str) -> Option<Shell> { + let shell = name + .trim() + .rsplit('/') + .next() + .unwrap_or(name) + .trim_start_matches('-') + .to_ascii_lowercase(); + + match shell.as_str() { + "bash" => Some(Shell::Bash), + "zsh" => Some(Shell::Zsh), + "fish" => Some(Shell::Fish), + _ => None, + } +} + +fn init_command(shell: Shell) -> String { + format!("atuin init {}", shell.as_str()) +} + +fn render_init(shell: Shell) -> String { + let init_command = init_command(shell); + + match shell { + Shell::Bash | Shell::Zsh => format!( + r#"if [[ "$-" == *i* ]] && [[ -t 0 ]] && [[ -t 1 ]]; then + _atuin_shell_tmux_current="${{TMUX:-}}" + _atuin_shell_tmux_previous="${{ATUIN_SHELL_TMUX:-}}" + + if [[ -z "${{ATUIN_SHELL_ACTIVE:-}}" ]] || [[ "$_atuin_shell_tmux_current" != "$_atuin_shell_tmux_previous" ]]; then + export ATUIN_SHELL_ACTIVE=1 + export ATUIN_SHELL_TMUX="$_atuin_shell_tmux_current" + exec atuin-shell + fi + + unset _atuin_shell_tmux_current _atuin_shell_tmux_previous +fi + +eval "$({init_command})" +"# + ), + Shell::Fish => format!( + r#"if status is-interactive; and test -t 0; and test -t 1 + set -l _atuin_shell_tmux_current "" + if set -q TMUX + set _atuin_shell_tmux_current "$TMUX" + end + + set -l _atuin_shell_tmux_previous "" + if set -q ATUIN_SHELL_TMUX + set _atuin_shell_tmux_previous "$ATUIN_SHELL_TMUX" + end + + if not set -q ATUIN_SHELL_ACTIVE + set -gx ATUIN_SHELL_ACTIVE 1 + set -gx ATUIN_SHELL_TMUX "$_atuin_shell_tmux_current" + exec atuin-shell + else if test "$_atuin_shell_tmux_current" != "$_atuin_shell_tmux_previous" + set -gx ATUIN_SHELL_ACTIVE 1 + set -gx ATUIN_SHELL_TMUX "$_atuin_shell_tmux_current" + exec atuin-shell + end +end + +{init_command} | source +"# + ), + } +} + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Some(Cmd::Init(init)) => { + if let Err(err) = init.run() { + eprintln!("atuin-shell: {err}"); + std::process::exit(1); + } + } + None => app::main(), + } +} + +#[cfg(any(not(unix), target_os = "illumos"))] +mod app { + pub(crate) fn main() { + eprintln!("atuin-shell currently supports unix platforms excluding illumos"); + std::process::exit(1); + } +} + +#[cfg(all(unix, not(target_os = "illumos")))] +mod app { + use std::io::{Read, Write}; + + use crossterm::terminal; + use portable_pty::{CommandBuilder, PtySize, native_pty_system}; + + pub(crate) fn main() { + if let Err(e) = run() { + let _ = terminal::disable_raw_mode(); + eprintln!("atuin-shell: {e:#}"); + std::process::exit(1); + } + } + + fn run() -> eyre::Result<()> { + let (cols, rows) = terminal::size()?; + + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| eyre::eyre!("{e:#}"))?; + + let mut cmd = CommandBuilder::new_default_prog(); + cmd.cwd(std::env::current_dir()?); + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|e| eyre::eyre!("{e:#}"))?; + + // Close slave side in parent process + drop(pair.slave); + + let mut pty_reader = pair + .master + .try_clone_reader() + .map_err(|e| eyre::eyre!("{e:#}"))?; + let mut pty_writer = pair + .master + .take_writer() + .map_err(|e| eyre::eyre!("{e:#}"))?; + + // Handle terminal resize via SIGWINCH + { + use signal_hook::consts::SIGWINCH; + use signal_hook::iterator::Signals; + + let master = pair.master; + let mut signals = Signals::new([SIGWINCH])?; + + std::thread::spawn(move || { + for _ in signals.forever() { + if let Ok((cols, rows)) = terminal::size() { + let _ = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } + } + }); + } + + terminal::enable_raw_mode()?; + + // PTY -> stdout (with OSC 133 parsing) + let stdout_thread = std::thread::spawn(move || { + let mut stdout = std::io::stdout(); + let mut parser = crate::osc133::Parser::new(); + let mut buf = [0u8; 8192]; + loop { + match pty_reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + parser.push(&buf[..n], |_event| { + // Zone transitions are tracked inside the parser. + // Callers can query parser.zone() after push. + }); + if stdout.write_all(&buf[..n]).is_err() { + break; + } + let _ = stdout.flush(); + } + } + } + }); + + // stdin -> PTY + std::thread::spawn(move || { + let mut stdin = std::io::stdin(); + let mut buf = [0u8; 8192]; + loop { + match stdin.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if pty_writer.write_all(&buf[..n]).is_err() { + break; + } + } + } + } + }); + + let status = child.wait()?; + let _ = stdout_thread.join(); + + let _ = terminal::disable_raw_mode(); + + std::process::exit(process_exit_code(status.exit_code())); + } + + fn process_exit_code(code: u32) -> i32 { + i32::try_from(code).unwrap_or(1) + } + + #[cfg(test)] + mod tests { + use super::process_exit_code; + + #[test] + fn process_exit_code_preserves_valid_values() { + assert_eq!(process_exit_code(0), 0); + assert_eq!(process_exit_code(127), 127); + assert_eq!(process_exit_code(i32::MAX as u32), i32::MAX); + } + + #[test] + fn process_exit_code_defaults_when_out_of_range() { + assert_eq!(process_exit_code(i32::MAX as u32 + 1), 1); + } + } +} + +#[cfg(test)] +mod tests { + use super::{Shell, init_command, render_init, shell_from_name}; + + #[test] + fn shell_from_name_handles_paths() { + assert_eq!(shell_from_name("/bin/zsh"), Some(Shell::Zsh)); + assert_eq!(shell_from_name("/usr/local/bin/bash"), Some(Shell::Bash)); + assert_eq!(shell_from_name("fish"), Some(Shell::Fish)); + } + + #[test] + fn init_command_is_bootstrap_only() { + let command = init_command(Shell::Zsh); + assert_eq!(command, "atuin init zsh"); + } + + #[test] + fn posix_init_uses_exec_and_tmux_guard() { + let script = render_init(Shell::Bash); + assert!(script.contains("exec atuin-shell")); + assert!(script.contains("ATUIN_SHELL_TMUX")); + assert!(script.contains("eval \"$(atuin init bash)\"")); + } + + #[test] + fn fish_init_uses_source() { + let script = render_init(Shell::Fish); + assert!(script.contains("exec atuin-shell")); + assert!(script.contains("atuin init fish | source")); + } +} |
