shellswain is a neighborly bash library you can use to build simpler event-driven bash profile/bashrc scripts and modules.
- loaded modules can emit their own arbitrary events and subscribe to events provided by other modules
- a core set of events emitted before the first prompt, before and after each command invocation, and before shell exit
- a way to set up command-specific before/run/after events (the run event enables you to customize how the command runs)
- shared metadata about the most-recent command (the command, when it ran, how long it took, what the exit status was, etc.)
- traps/signals automatically namespaced per script
These features form an efficient foundation for mutual cooperation between modules and user code. They make it easy to share access to scarce shell resources and minimize duplicated work.
I package shellswain and its dependencies with Nix and resholve for my own use, so that's the easiest/recommended way to incorporate it into a project.
Note: Aside from bash 5.1+, shellswain's dependencies are pure bash. It doesn't require Nix and should be easy enough to package/vendor/inline outside of the Nix ecosystem. You'll also need:
- signal/trap namespacing provided by https://github.com/abathur/comity
- the event API provided by https://github.com/bashup/events (this is pulled in via comity, since comity also uses it)
You can find a real-world example of how I do this in https://github.com/abathur/shell-hag. That project is a little complex, so I'll break down the basic steps:
-
Include it in your Bash source. I use a guard to avoid wasting time sourcing it again it in case more than one module uses shellswain:
if [[ -z "$SHELLSWAIN_ABOARD" ]]; then # shellcheck disable=SC1090 source shellswain.bash fi
For reference, here's the equivalent statement in shell-hag.
-
Package your script/module with Nix + resholve and supply shellswain as a dependency. Here's a basic skeleton:
{ lib , resholve , shellswain }: resholve.mkDerivation rec { pname = "your_project"; version = "unreleased"; src = lib.cleanSource ./.; # src = fetchFromGitHub { # owner = "you"; # repo = "${pname}"; # rev = "v${version}"; # sha256 = "..."; # }; solutions = { profile = { scripts = [ "bin/your_module.bash" ]; interpreter = "none"; inputs = [ shellswain ]; }; }; # ... }
If it isn't clear how to turn this into a working Nix expression, I recommend referring to:
- shellswain's own shellswain.nix is a simple, complete example of how to use resholve with Nix
- resholve's Nix API is documented in the nixpkgs README for resholve
Note: If you just want to play around with shellswain, you can run
nix develop github:abathur/shellswain
to open a bare bash shell with shellswain pre-sourced. There are also some basic usage examples in the examples directory.
There are four main ~areas of shellswain's public API:
-
shellswain publishes events related to shell init/teardown:
-
swain:before_first_prompt
(emitted the first time bash evaluatesPROMPT_COMMAND
)Note: In early versions of shellswain this assumed ownership of
PROMPT_COMMAND
, though shellswain should now also be compatible with the new array-basedPROMPT_COMMAND
. -
swain:before_exit
(emitted on HUP and EXIT)
Your code can subscribe to these events using the bashup.events API (see https://github.com/bashup/events for more). For example:
event on swain:before_exit _your_teardown_function
-
-
shellswain publishes events before and after every command invocation:
-
swain:before_command
(emitted right after bash evaluates PS0)Note: This does assume shellswain owns PS0. Your code can print whatever it would like as the before-command prompt by attaching a handler to the swain:before_command event.
-
swain:after_command
(emitted each time bash evaluatesPROMPT_COMMAND
except the first; seeswain:before_first_prompt
)
-
-
if you instruct shellswain to "track" specific commands, it will emit command-specific before/run/after events.
Command-specific tracking is designed to encourage a lazy-initialization pattern. The goal is to avoid doing setup work for commands until the user actually invokes them. This may feel like a lot of conceptual overhead if you only want to track a single command--but the goal is making sure shell startup time is snappy even if users/modules are tracking scores of commands.
-
You call
swain.track <command> <your_init_callback>
to bootstrap deferred tracking for a command. shellswain will runyour_init_callback <command>
the first time the user invokes the tracked command.Note:
swain.track
can only register one init callback, but other modules (or user code, if you're building a layer over shellswain) can useswain.hook.init_command <command> <callback> <all other args>
to subscribe to a one-time event that shellswain will emit immediately after running the init callback.These callbacks are invoked as
callback <command> [<other args>...]
. -
You can then use either kind of init callback to set up command-specific event listeners (and perform any other init you need).
When users run a tracked command, shellswain emits three command-specific "phase" events:
before
,run
, andafter
.You can set up a listener by calling:
swain.phase.listen <phase> <command> <callback> [<other args>...]
shellswain will invoke your callback as:
callback <command> [<other args from swain.phase.listen>...] [<args the user invoked command with>...]
Note: shellswain also has a mechanism for currying additional arguments to a phase. You can call
swain.phase.curry_args <phase> <command> [<other args>...]
to inject args before those from the user's invocation.Instead of spending a second on a long computation in both the before and after phases, this enables you to compute it once in the before phase and curry the result to the after phase.
The
run
phase is ~special--it's responsible for actually running the command. If none of your init callbacks register a run phase listener, shellswain will register a default runner (that just runs the command).If you register a run phase listener, make sure it runs the command!
-
-
shellswain maintains a global associative array,
swain
, with information about command run so that each plugin/module doesn't have to compute them independently. In the order they are recorded:Before the command runs:
-
start_time
This is a human-readable datetime as reported by
printf '%(%a %b %d %Y %T)T'
(ex: "Sun Jan 15 2023 12:34:45"). -
command_number
The command number as reported by
fc -lr -0
. -
command
The most-recently run command (unexpanded) as reported by
fc -lr -0
. -
start_timestamp
A microsecond-precision timestamp created by removing the
.
from$EPOCHREALTIME
(ex: "1673807685512462").Caution: This value will be slightly different during the
swain:before_command
and afterwards. It is recorded once before running anyswain:before_command
listeners, and updated after.shellswain does this so that it can both give some timestamp to plugins that need one before the command runs and exclude the time
swain:before_command
listeners take to run from theduration
it computes after the command runs.
After the command runs (immediately before
swain:after_command
is emitted):-
end_timestamp
A microsecond-precision timestamp created by removing the
.
from$EPOCHREALTIME
(ex: "1673800973404806"). -
pipestatus
Command exit statuses as reported by
${PIPESTATUS[@]}
(ex: "0", "0 1 0"). -
duration
How long the command took to run in microseconds as reported by
$((swain[end_timestamp] - swain[start_timestamp]))
(ex: "6687376").Note: While the time taken to run
swain:before_command
andswain:after_command
listeners are excluded from the calculated duration, it will still include whatever time it takes to run any phase listeners registered withswain.phase.listen
. -
end_time
This is a human-readable datetime as reported by
printf '%(%a %b %d %Y %T)T'
(ex: "Sun Jan 15 2023 12:34:45").
Caution: shellswain updates the
swain
variable in place. If you use any after-command value during theswain:before_command
event or any listener registered with swain.phase.listen, the values will still refer to the previous command run.(This may sound like a footgun, but in some cases it is exactly the behavior you want. A session-oriented shell history plugin, for example, might need the previous command's end time to create a new history file when you go more than an hour without running a command.)
-