//! `bread hooks install shell` — generates a shell hook script that reports //! terminal telemetry (command start/finish, cwd changes, remote-session //! start/end) to the bread daemon. //! //! # Why this exists as a generated file, not an rc-file edit //! //! This module deliberately **never touches the user's `.bashrc`, `.zshrc`, //! or fish `config.fish`.** It only writes self-contained hook script(s) //! under `~/.config/bread/hooks/` and prints the one-line `source` snippet //! the user needs to add themselves. Editing a user's shell startup file //! automatically is the kind of silent, hard-to-audit change that belongs //! to the user, not to a CLI subcommand — if `bread` got it wrong, or the //! user later doesn't want it, an rc-file edit is much harder to notice and //! undo than a printed snippet they chose to paste in. //! //! # Why bread-emit, not `bread emit` //! //! The generated hooks shell out to the separate `bread-emit` binary //! (`bread-emit/src/main.rs`), not `bread emit`. `bread-emit` skips clap //! parsing and the Tokio runtime entirely and never waits for a reply — it //! is cheap enough to call on every single shell prompt. The full `bread` //! CLI spins up an async runtime per invocation and would be perceptible //! latency if called twice per prompt. //! //! # Why both a `.sh` and a `.fish` file are always written //! //! `install_shell` always regenerates both `shell-hook.sh` (bash + zsh, a //! single file with an `if [ -n "$ZSH_VERSION" ]; ... elif [ -n //! "$BASH_VERSION" ]; ...` branch) and `shell-hook.fish` (fish has //! meaningfully different hook primitives and cannot source a POSIX-ish //! script anyway), regardless of which shell was detected or requested. //! This keeps both files present and up to date at fixed, predictable //! paths so a user who switches shells later doesn't need to re-run //! install — they just add the one extra `source` line to the new shell's //! rc file. Only the *printed* snippet is specific to the detected/forced //! shell. //! //! Existing hook files at these paths are always overwritten. Unlike git //! hooks, there is no ecosystem of third-party `shell-hook.sh` files a user //! might have installed by other means — this file is entirely owned and //! generated by this command, so overwriting on every run is safe and is //! in fact required to pick up script changes across `bread` upgrades. use anyhow::{bail, Result}; use std::fs; use std::path::PathBuf; /// Which shell to print the `source` snippet for. Detected from `$SHELL` /// unless the caller forces one via `bread hooks install shell --shell /// `. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TargetShell { Bash, Zsh, Fish, /// Detection failed (unrecognized or unset `$SHELL`); both snippets are /// printed and the user picks the one that matches their shell. Unknown, } impl TargetShell { fn parse(name: &str) -> Result { match name { "bash" => Ok(TargetShell::Bash), "zsh" => Ok(TargetShell::Zsh), "fish" => Ok(TargetShell::Fish), other => bail!( "bread: unrecognized shell '{}' (expected one of: bash, zsh, fish)", other ), } } /// Detect the caller's shell from `$SHELL`'s basename. This is a best /// effort: `$SHELL` reflects the user's login shell, which is normally /// also the shell running the CLI, but that's not guaranteed (e.g. /// invoked from inside a script run under a different interpreter). fn detect_from_env() -> TargetShell { let Ok(shell_path) = std::env::var("SHELL") else { return TargetShell::Unknown; }; let name = PathBuf::from(shell_path) .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); match name.as_str() { "bash" => TargetShell::Bash, "zsh" => TargetShell::Zsh, "fish" => TargetShell::Fish, _ => TargetShell::Unknown, } } } /// Returns the default hooks directory: `~/.config/bread/hooks`. pub fn hooks_dir() -> PathBuf { if let Some(cfg) = dirs::config_dir() { return cfg.join("bread").join("hooks"); } if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home) .join(".config") .join("bread") .join("hooks"); } PathBuf::from(".config/bread/hooks") } /// Install (or regenerate) the shell hook scripts and print the snippet the /// user needs to add to their own rc file. `shell` optionally forces /// bash/zsh/fish; otherwise the target shell is auto-detected from `$SHELL` /// purely to decide which snippet to print — both hook files are written /// either way (see module docs). pub fn install_shell(shell: Option) -> Result<()> { let target = match shell { Some(name) => TargetShell::parse(&name)?, None => TargetShell::detect_from_env(), }; let dir = hooks_dir(); fs::create_dir_all(&dir) .map_err(|e| anyhow::anyhow!("failed to create {}: {}", dir.display(), e))?; let sh_path = dir.join("shell-hook.sh"); write_hook_file(&sh_path, BASH_ZSH_HOOK_SCRIPT)?; let fish_path = dir.join("shell-hook.fish"); write_hook_file(&fish_path, FISH_HOOK_SCRIPT)?; println!(); match target { TargetShell::Bash | TargetShell::Zsh => { println!("Add this line to your ~/.bashrc or ~/.zshrc:"); println!(); println!(" source {}", sh_path.display()); } TargetShell::Fish => { println!("Add this line to your ~/.config/fish/config.fish:"); println!(); println!(" source {}", fish_path.display()); } TargetShell::Unknown => { println!( "bread: could not detect your shell from $SHELL; add whichever \ of these matches the shell you use interactively:" ); println!(); println!(" bash/zsh — add to ~/.bashrc or ~/.zshrc:"); println!(" source {}", sh_path.display()); println!(); println!(" fish — add to ~/.config/fish/config.fish:"); println!(" source {}", fish_path.display()); } } println!(); println!( "bread never edits rc files on its own — that line above is something \ only you should add." ); Ok(()) } fn write_hook_file(path: &PathBuf, contents: &str) -> Result<()> { let existed = path.exists(); fs::write(path, contents) .map_err(|e| anyhow::anyhow!("failed to write {}: {}", path.display(), e))?; if existed { println!("bread: regenerated {}", path.display()); } else { println!("bread: wrote {}", path.display()); } Ok(()) } // --------------------------------------------------------------------------- // Hook script bodies // --------------------------------------------------------------------------- // // Both scripts below shell out to `bread-emit` (not `bread emit`) for every // event, always backgrounded and with output discarded (`>/dev/null 2>&1 // &`), so a down, slow, or stalled daemon can never block the interactive // shell waiting for the next prompt. // // Event/kind pairs emitted (kept in sync with the daemon's normalizer): // bread.terminal.command.started (kind command.started) // bread.terminal.command.finished (kind command.finished) // bread.terminal.cwd.changed (kind cwd.changed) // bread.remote.session.started (kind session.started) // bread.remote.session.ended (kind session.ended) // // `bread-emit`'s positional argument is technically redundant once // --source/--kind are both given (the daemon derives the real event name // from source+kind and ignores the literal positional string in that // mode), but a sensible name is still passed since bread-emit requires a // positional argument. /// Combined bash + zsh hook script written to `~/.config/bread/hooks/shell-hook.sh`. /// /// zsh has native `preexec`/`precmd`/`chpwd` hooks; bash has none of the /// three, so its branch approximates them with a `trap ... DEBUG` (guarded /// so it only fires once per prompt, not once per pipeline stage) and a /// `PROMPT_COMMAND` that both plays the precmd role and polls `$PWD` against /// a remembered previous value to emulate `chpwd`. const BASH_ZSH_HOOK_SCRIPT: &str = r#"# bread shell hook — generated by `bread hooks install shell`. # Do not hand-edit; rerun `bread hooks install shell` to regenerate. # # Reports terminal telemetry (command start/finish, cwd changes, remote # session start/end) to the bread daemon via the lightweight `bread-emit` # binary. Every call below is backgrounded and has its output discarded so a # down or slow daemon can never delay the shell prompt. # # Assumes GNU date (for `date +%s%3N`, millisecond epoch) and GNU sed (for # the JSON-escaping helper below) — both are standard on Arch Linux. # Escape a string for embedding inside a JSON string literal: backslashes, # double quotes, tabs, carriage returns, and embedded newlines. Backslashes # must be escaped first, before any escape sequence that introduces new # backslashes, or the newly-added backslashes would themselves get doubled. _bread_json_escape() { printf '%s' "$1" \ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' \ | sed ':a;N;$!ba;s/\n/\\n/g' } # --- Remote session detection (runs once at shell-init time) -------------- # Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported # so nested subshells (and the EXIT trap that fires only in this branch) # don't each fire their own started/ended pair. if { [ -n "$SSH_TTY" ] || [ -n "$SSH_CONNECTION" ]; } && [ -z "$BREAD_SSH_ANNOUNCED" ]; then export BREAD_SSH_ANNOUNCED=1 # SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall # back to SSH_CLIENT (same leading field) if it's unset for some reason. _bread_ssh_host=$(printf '%s' "${SSH_CONNECTION:-$SSH_CLIENT}" | awk '{print $1}') _bread_esc_host=$(_bread_json_escape "$_bread_ssh_host") bread-emit bread.remote.session.started --source remote --kind session.started \ --data "{\"host\":\"$_bread_esc_host\"}" >/dev/null 2>&1 & _bread_ssh_exit_hook() { _bread_esc_host_exit=$(_bread_json_escape "$_bread_ssh_host") bread-emit bread.remote.session.ended --source remote --kind session.ended \ --data "{\"host\":\"$_bread_esc_host_exit\"}" >/dev/null 2>&1 & } trap _bread_ssh_exit_hook EXIT fi if [ -n "$ZSH_VERSION" ]; then # --- zsh: preexec / precmd / chpwd are native --------------------------- autoload -Uz add-zsh-hook _bread_preexec() { _bread_cmd_start=$(date +%s%3N) _bread_cmd_cwd="$PWD" _bread_cmd_str="$1" local esc_cmd esc_cwd esc_cmd=$(_bread_json_escape "$1") esc_cwd=$(_bread_json_escape "$PWD") bread-emit bread.terminal.command.started --source terminal --kind command.started \ --data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 & } add-zsh-hook preexec _bread_preexec _bread_precmd() { local exit_code=$? if [ -n "$_bread_cmd_str" ]; then local now duration_ms esc_cmd esc_cwd now=$(date +%s%3N) duration_ms=$(( now - _bread_cmd_start )) esc_cmd=$(_bread_json_escape "$_bread_cmd_str") esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd") bread-emit bread.terminal.command.finished --source terminal --kind command.finished \ --data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 & _bread_cmd_str="" fi } add-zsh-hook precmd _bread_precmd _bread_chpwd() { local esc_cwd esc_prev esc_cwd=$(_bread_json_escape "$PWD") esc_prev=$(_bread_json_escape "${OLDPWD:-$PWD}") bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \ --data "{\"cwd\":\"$esc_cwd\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 & } add-zsh-hook chpwd _bread_chpwd elif [ -n "$BASH_VERSION" ]; then # --- bash: no native preexec/precmd/chpwd; approximate them ------------- # trap ... DEBUG fires before every simple command. `_bread_preexec_guard` # ensures only the first simple command between prompts is recorded, not # every stage of a pipeline or every `;`-separated command on one line. # This relies on bash's default of NOT propagating the DEBUG trap into # function calls (i.e. `set -o functrace`/`shopt -s extdebug` are off); # if something in the user's environment turns those on, the guard below # could fire more than once per prompt. _bread_preexec() { [ -n "$_bread_preexec_guard" ] && return case "$BASH_COMMAND" in _bread_precmd*|"$PROMPT_COMMAND") return ;; esac _bread_preexec_guard=1 _bread_cmd_start=$(date +%s%3N) _bread_cmd_cwd="$PWD" _bread_cmd_str="$BASH_COMMAND" local esc_cmd esc_cwd esc_cmd=$(_bread_json_escape "$BASH_COMMAND") esc_cwd=$(_bread_json_escape "$PWD") bread-emit bread.terminal.command.started --source terminal --kind command.started \ --data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 & } trap '_bread_preexec' DEBUG _bread_precmd() { local exit_code=$? if [ -n "$_bread_preexec_guard" ]; then local now duration_ms esc_cmd esc_cwd now=$(date +%s%3N) duration_ms=$(( now - _bread_cmd_start )) esc_cmd=$(_bread_json_escape "$_bread_cmd_str") esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd") bread-emit bread.terminal.command.finished --source terminal --kind command.finished \ --data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 & fi _bread_preexec_guard="" # chpwd emulation: bash has no native hook, so compare $PWD against a # remembered previous value on every prompt. if [ "$PWD" != "${_bread_prev_pwd:-$PWD}" ]; then local esc_cwd2 esc_prev esc_cwd2=$(_bread_json_escape "$PWD") esc_prev=$(_bread_json_escape "${_bread_prev_pwd:-$PWD}") bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \ --data "{\"cwd\":\"$esc_cwd2\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 & _bread_prev_pwd="$PWD" fi } PROMPT_COMMAND="_bread_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}" _bread_prev_pwd="$PWD" fi "#; /// Fish hook script written to `~/.config/bread/hooks/shell-hook.fish`. /// /// Fish's job-control primitives are meaningfully nicer than bash/zsh here: /// `fish_preexec`/`fish_postexec` are real events (no DEBUG-trap games), /// `$status` in the postexec handler gives the exit code directly, and /// `$CMD_DURATION` is already a builtin millisecond duration — no manual /// timestamp math needed. `string escape --style=json` (fish >= 3.3) does /// the JSON-string-literal escaping, quotes included, in one call. const FISH_HOOK_SCRIPT: &str = r#"# bread shell hook (fish) — generated by `bread hooks install shell`. # Do not hand-edit; rerun `bread hooks install shell` to regenerate. # # Reports terminal telemetry (command start/finish, cwd changes, remote # session start/end) to the bread daemon via the lightweight `bread-emit` # binary. Every call below is backgrounded and has its output discarded so a # down or slow daemon can never delay the shell prompt. # # Requires fish >= 3.3 for `string escape --style=json`. function _bread_json_escape --description 'Escape a value as a JSON string literal (surrounding quotes included)' string escape --style=json -- $argv[1] end function _bread_preexec --on-event fish_preexec --description 'bread: emit command.started' set -g _bread_cmd_cwd $PWD set -l cmd_json (_bread_json_escape $argv[1]) set -l cwd_json (_bread_json_escape $PWD) bread-emit bread.terminal.command.started --source terminal --kind command.started \ --data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json}" >/dev/null 2>&1 & end function _bread_postexec --on-event fish_postexec --description 'bread: emit command.finished' # $status must be captured first, before any other command in this # function has a chance to overwrite it. set -l exit_code $status set -l cmd_json (_bread_json_escape $argv[1]) set -l cwd_json (_bread_json_escape $_bread_cmd_cwd) bread-emit bread.terminal.command.finished --source terminal --kind command.finished \ --data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json, \"exit_code\": $exit_code, \"duration_ms\": $CMD_DURATION}" >/dev/null 2>&1 & end function _bread_pwd_changed --on-variable PWD --description 'bread: emit cwd.changed' status is-interactive; or return set -l cwd_json (_bread_json_escape $PWD) set -l prev_json (_bread_json_escape $_bread_prev_pwd) bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \ --data "{\"cwd\": $cwd_json, \"prev_cwd\": $prev_json}" >/dev/null 2>&1 & set -g _bread_prev_pwd $PWD end set -g _bread_prev_pwd $PWD # --- Remote session detection (runs once at shell-init time) -------------- # Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported # so nested fish subshells (and the fish_exit handler, which is only # registered in this branch) don't each fire their own started/ended pair. set -l _bread_is_ssh 0 if test -n "$SSH_TTY" set _bread_is_ssh 1 else if test -n "$SSH_CONNECTION" set _bread_is_ssh 1 end if test $_bread_is_ssh -eq 1; and test -z "$BREAD_SSH_ANNOUNCED" set -gx BREAD_SSH_ANNOUNCED 1 # SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall # back to SSH_CLIENT (same leading field) if it's unset for some reason. set -l _bread_conn_str $SSH_CONNECTION if test -z "$_bread_conn_str" set _bread_conn_str $SSH_CLIENT end set -g _bread_ssh_host (string split ' ' -- $_bread_conn_str)[1] set -l host_json (_bread_json_escape $_bread_ssh_host) bread-emit bread.remote.session.started --source remote --kind session.started \ --data "{\"host\": $host_json}" >/dev/null 2>&1 & function _bread_ssh_exit_hook --on-event fish_exit --description 'bread: emit remote session.ended' set -l host_json (_bread_json_escape $_bread_ssh_host) bread-emit bread.remote.session.ended --source remote --kind session.ended \ --data "{\"host\": $host_json}" >/dev/null 2>&1 & end end "#; #[cfg(test)] mod tests { use super::*; #[test] fn parse_accepts_known_shells() { assert_eq!(TargetShell::parse("bash").unwrap(), TargetShell::Bash); assert_eq!(TargetShell::parse("zsh").unwrap(), TargetShell::Zsh); assert_eq!(TargetShell::parse("fish").unwrap(), TargetShell::Fish); } #[test] fn parse_rejects_unknown_shell() { assert!(TargetShell::parse("powershell").is_err()); assert!(TargetShell::parse("").is_err()); } #[test] fn detect_from_env_reads_shell_basename() { std::env::set_var("SHELL", "/usr/bin/zsh"); assert_eq!(TargetShell::detect_from_env(), TargetShell::Zsh); std::env::set_var("SHELL", "/bin/bash"); assert_eq!(TargetShell::detect_from_env(), TargetShell::Bash); std::env::set_var("SHELL", "/usr/bin/fish"); assert_eq!(TargetShell::detect_from_env(), TargetShell::Fish); std::env::set_var("SHELL", "/usr/bin/tcsh"); assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown); std::env::remove_var("SHELL"); assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown); } #[test] fn hook_scripts_reference_bread_emit_not_bread_emit_cli() { // Guard against accidentally shelling out to the slow `bread emit` // subcommand instead of the lightweight `bread-emit` binary. for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] { assert!(script.contains("bread-emit ")); assert!(!script.contains("bread emit ")); } } #[test] fn hook_scripts_background_every_emit_call() { // Every bread-emit invocation must be backgrounded with output // discarded so a stalled daemon can never block the shell. A single // invocation can span multiple physical lines via a trailing `\` // continuation, so continuation lines are joined into one logical // statement before checking, rather than inspecting each line in // isolation. for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] { for statement in join_line_continuations(script) { if statement.contains("bread-emit ") { assert!( statement.contains(">/dev/null 2>&1 &"), "statement not backgrounded/discarded: {statement}" ); } } } } /// Joins physical lines ending in a trailing `\` continuation into a /// single logical statement, so multi-line shell invocations can be /// checked as a whole rather than line-by-line. fn join_line_continuations(script: &str) -> Vec { let mut statements = Vec::new(); let mut current = String::new(); for line in script.lines() { let trimmed_end = line.trim_end(); if let Some(rest) = trimmed_end.strip_suffix('\\') { current.push_str(rest); current.push(' '); } else { current.push_str(trimmed_end); statements.push(std::mem::take(&mut current)); } } if !current.is_empty() { statements.push(current); } statements } }