Extending Lush with hook functions and the plugin system
Lush provides a hook system that lets you execute custom code at specific points in the shell's operation. This enables customization of shell behavior, integration with external tools, and creation of advanced workflows.
| Hook | Trigger |
|---|---|
precmd |
After command completes, before prompt displays |
preexec |
After command entered, before execution |
chpwd |
After current directory changes |
periodic |
At regular intervals (controlled by PERIOD) |
- Zsh-compatible hook function syntax
- Hook arrays for multiple handlers
- Automatic error handling
- Performance monitoring integration
The precmd function runs after each command completes, just before the prompt is displayed.
precmd() {
# Update terminal title
echo -ne "\033]0;${USER}@${HOSTNAME}: ${PWD}\007"
}Use cases:
- Update terminal title
- Refresh prompt information
- Log command completion
- Update status displays
Timing: Runs after the previous command's exit status is set but before the prompt renders.
The preexec function runs after a command is entered but before it executes. The command text is passed as an argument.
preexec() {
local cmd="$1"
echo "About to run: $cmd"
# Log to file
echo "$(date '+%Y-%m-%d %H:%M:%S') $cmd" >> ~/.command_log
}Use cases:
- Command logging/auditing
- Command timing (start timer)
- Send notifications
- Modify environment before execution
Arguments:
$1: The command line that will be executed
The chpwd function runs whenever the current directory changes (via cd, pushd, popd, etc.).
chpwd() {
# Auto-ls on directory change
ls
# Or show directory info
echo "Now in: $PWD"
# Check for project files
if [ -f "package.json" ]; then
echo "Node.js project detected"
fi
}Use cases:
- Auto-list directory contents
- Activate/deactivate virtual environments
- Load project-specific configuration
- Update prompt with project info
Note: Runs for any directory change, including those in scripts.
The periodic function runs at regular intervals, controlled by the PERIOD variable (in seconds).
# Run every 60 seconds
PERIOD=60
periodic() {
# Check for new mail
if [ -s "$MAIL" ]; then
echo "You have mail"
fi
# Or check system status
# ...
}Use cases:
- Check for new mail
- Monitor system resources
- Fetch remote status updates
- Background sync operations
Timing: Checked during precmd. If PERIOD seconds have elapsed since the last call, periodic runs.
For cases where you need multiple handlers for the same event, use hook arrays. This is useful when combining multiple plugins or configurations.
| Hook | Array |
|---|---|
precmd |
precmd_functions |
preexec |
preexec_functions |
chpwd |
chpwd_functions |
periodic |
periodic_functions |
# Define handler functions
update_title() {
echo -ne "\033]0;${PWD}\007"
}
log_prompt() {
logger -t shell "prompt displayed in $PWD"
}
check_todos() {
if [ -f "TODO.md" ]; then
echo "TODO.md found"
fi
}
# Add to precmd array
precmd_functions+=(update_title)
precmd_functions+=(log_prompt)
precmd_functions+=(check_todos)
# All three run before each prompt- Named function (
precmd,preexec, etc.) runs first - Array functions run in order
precmd() {
echo "Main precmd" # Runs first
}
precmd_functions=(handler1 handler2)
# handler1 runs second, handler2 runs third# Remove specific handler
precmd_functions=(${precmd_functions[@]/handler1})
# Clear all handlers
precmd_functions=()# List current handlers
echo "precmd handlers: ${precmd_functions[@]}"
# Check if handler exists
if [[ " ${precmd_functions[@]} " =~ " handler1 " ]]; then
echo "handler1 is registered"
fi# Start timer in preexec, report in precmd
_cmd_start=0
preexec() {
_cmd_start=$(date +%s)
}
precmd() {
if [ $_cmd_start -gt 0 ]; then
local elapsed=$(($(date +%s) - _cmd_start))
if [ $elapsed -gt 5 ]; then
echo "Command took ${elapsed}s"
fi
_cmd_start=0
fi
}precmd() {
# Format: user@host: directory
echo -ne "\033]0;${USER}@${HOSTNAME%%.*}: ${PWD/#$HOME/~}\007"
}
preexec() {
# Show running command in title
echo -ne "\033]0;$1\007"
}chpwd() {
# Python virtual environment
if [ -f "venv/bin/activate" ]; then
source venv/bin/activate
elif [ -n "$VIRTUAL_ENV" ]; then
deactivate
fi
# Node.js version management
if [ -f ".nvmrc" ]; then
nvm use 2>/dev/null
fi
}chpwd() {
# Git repository info
if [ -d ".git" ]; then
echo "Git repo: $(git config --get remote.origin.url 2>/dev/null || echo 'local')"
fi
# Project type
if [ -f "Cargo.toml" ]; then
echo "Rust project"
elif [ -f "go.mod" ]; then
echo "Go project"
elif [ -f "package.json" ]; then
echo "Node.js project"
elif [ -f "Makefile" ]; then
echo "Make project"
fi
}preexec() {
# Log all commands with timestamp
local log_file="$HOME/.shell_history_log"
echo "$(date '+%Y-%m-%d %H:%M:%S') [$$] $1" >> "$log_file"
}PERIOD=300 # Every 5 minutes
periodic() {
# Check disk space
local usage=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$usage" -gt 90 ]; then
echo "WARNING: Disk usage at ${usage}%"
fi
# Check memory
# ...
}preexec() {
_last_cmd="$1"
_cmd_start=$(date +%s)
}
precmd() {
local elapsed=$(($(date +%s) - _cmd_start))
# Notify for long-running commands
if [ $elapsed -gt 30 ]; then
# macOS
osascript -e "display notification \"Completed in ${elapsed}s\" with title \"$_last_cmd\""
# Or Linux with notify-send
# notify-send "$_last_cmd" "Completed in ${elapsed}s"
fi
}Lush does not yet ship a dedicated plugin loader / manager. That work is tracked under "User extensibility / plugins -- Not yet implemented" in the README. What lush does ship today is enough to build the same patterns by composing existing mechanisms:
- Hook functions (this document) for lifecycle behavior.
display lle widgetfor custom editing actions bound to keys.display lle hookfor symmetric registration of widget-side hooks.display lle segmentfor custom prompt segments backed by shell variables.sourceof arbitrary shell scripts from~/.config/lush/lushrcfor any extra setup.
See LLE_GUIDE.md "Customization" for the widget/hook/segment trio. The patterns below show how to organize an extension as a self-contained directory while we wait for the formal plugin system.
Even without a loader, the directory layout below is the convention extensions will eventually be packaged in:
~/.config/lush/extensions/my_ext/
init.lush # Main entry point sourced from lushrc
README.md # Documentation
# In ~/.config/lush/lushrc
source ~/.config/lush/extensions/my_ext/init.lush# ~/.config/lush/extensions/git_status/init.lush
# Extension: git_status
# Description: Show git status as a custom LLE segment
_git_status_compute() {
if git rev-parse --git-dir >/dev/null 2>&1; then
local branch
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
local dirty=""
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
dirty="*"
fi
GIT_STATUS_SEGMENT=" ($branch$dirty)"
else
GIT_STATUS_SEGMENT=""
fi
}
# A precmd hook keeps the segment variable current
precmd_functions+=(_git_status_compute)
# Register a custom LLE segment backed by the variable
display lle segment add git_status GIT_STATUS_SEGMENT
# Provide an unload function
git_status_unload() {
precmd_functions=("${precmd_functions[@]/_git_status_compute}")
display lle segment remove git_status
unset GIT_STATUS_SEGMENT
}- Namespace your functions: Prefix with the extension name.
- Provide an unload function: Allow clean removal even without a manager.
- Don't pollute global scope: Use
localinside helpers; the exposed surface should be a small, named set of variables and functions. - Handle missing dependencies: Check
command -vbefore using external tools. - Document the extension: Include a README beside
init.lush.
The eventual plugin manager will add: enable/disable lifecycle,
dependency resolution, a permission model, and a repository surface.
Conventions in this document are designed to migrate forward -- an
extension organized as ~/.config/lush/extensions/<name>/init.lush
will be a one-line move to whatever the manager ends up requiring.
Hook functions run frequently. Keep them fast:
# BAD: Slow hook
precmd() {
# Running git status on every prompt is slow
git_status=$(git status --porcelain 2>/dev/null)
}
# GOOD: Cache expensive operations
_cached_git_status=""
_cached_git_dir=""
precmd() {
local git_dir=$(git rev-parse --git-dir 2>/dev/null)
if [ "$git_dir" != "$_cached_git_dir" ]; then
_cached_git_dir="$git_dir"
_cached_git_status=$(git status --porcelain 2>/dev/null)
fi
}Hooks should not break the shell:
precmd() {
# Wrap in subshell or handle errors
(
set -e
risky_operation
) 2>/dev/null || true
}# Enable debugging to see hook execution
debug on 2
# Run a command to trigger hooks
ls
debug offOnly run hooks when needed:
# Only in interactive shells
precmd() {
[[ $- == *i* ]] || return
# ... interactive-only code
}
# Only in specific directories
chpwd() {
[[ $PWD == ~/projects/* ]] || return
# ... project-specific code
}Control execution order with arrays:
# Critical hooks first
precmd_functions=(critical_hook normal_hook optional_hook)
# Or insert at specific position
precmd_functions=(critical ${precmd_functions[@]} final_hook)- USER_GUIDE.md - Complete shell reference
- CONFIGURATION.md - Configuration reference
- CONFIGURATION.md - Mode requirements for hooks