bread/bread-cli/src/hooks_shell.rs

512 lines
22 KiB
Rust

//! `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
/// <name>`.
#[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<TargetShell> {
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<String>) -> 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 <event> 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<String> {
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
}
}