pisciform
is a fish
function that creates other functions. Specifically, it turns a function or built-in from another shell in to a fish
function.
Install with fisher:
fisher install dljsjr/pisciform.fish
Fish is a terrific shell. I first explored it over 10 years ago. One of the main blockers that prevented me from fully switching, though, was collaboration: If a professional or collaborative environment, you need to be able to share your tools with others easily. Especially as a Staff+ engineer, where part of your role is to be a force multiplier for your team(s).
Over the years, it's become a lot easier to share most stuff:
- Out of the box, shell scripts with a proper shebang were always portable and worked fine
- Awesome tools like bass and replay already exist and do a pretty good job handling the specific use case of
source
'ing bash scripts. But they have edge cases, are limited in what they do, and they only target bash.
That leaves one last pretty large hole as well, though: Shell functions. Which really sucked for me, because I write a lot of shell functions.
Why shell functions?
The short answer is that a shell function can mutate the calling shell, unlike a shell script. It's similar to when a script gets sourced. I've been using zsh
for a very long time, long before macOS made it its new default. And zsh
has a really awesome facility for defining and autoloading user-defined functions, meaning I had accumulated a lot of them over the years for automations where I wanted to mutate my shell environment.
$ pisciform --help
NAME:
pisciform - Create a fish function/alias for invoking a Bash/ZSH/Posix shell function and capturing environment changes
USAGE:
pisciform [-h|--help] [-v|--verbose] [--interactive] [--login] [{-f|--file}|{-b|--builtin}] [--sh|--zsh|--bash] [--source ...] FUNCTION_NAME
OPTIONS:
-h, --help print this usage, then return
-v, --verbose Sets a "verbose" flag for tracing the wrapped process, and generates a verbose version of the 'runner'
--interactive Set the interactive flag on the runner shell
--login Set the login flag on the runner shell.
--sh, --zsh, --bash Mutually exclusive. Selects which runner to use. Defaults to `sh`
-b, --builtin Instructs the runner that the command being wrapped is a shell built-in instead of a function defined in a file
-a, --autoload Only availble when `--zsh` is set. The FUNCTION_NAME positional argument should be replaced with the path to
a file that contains an autoloadable function definition. The runner will pass the given file to `autoload -U`.
The name of the file becomes the function name, and the containing directory for the file will be appended
to the runner's `fpath`.
--init-file File to `source` before invoking the command. May be specified more than once to source multiple files.
Useful for shells like bash or sh where a user function might be defined in an RC file. Note that these files
will be sourced *before* the pre-execution environment is captured, so environment modifications performed by
these files will *not* be propagated in to the containing shell; they'll only be made available to the function
invocation.
EXAMPLES
# creates a fish function called `do_something` that invokes the autoloadable ZSH function in the given file
pisciform --interactive --zsh --file "$HOME/.zfunc/do_something"
# creates a fish function called `foo` around a bash function called `foo`, where foo is a function defined in the file ~/.bashfuncs, so the file must be sourced first.
pisciform --interactive --bash --init-file ~/.bashfuncs foo
As I mentioned, part of my motiviation for creating this was that I was coming from ZSH and I had accumulated a lot of functions.
I store all of my function definitions for autoload
in a directory $HOME/.zfunc
, with subdirs for things like functions that are specific to work or specific topics.
In ZSH, I autoloaded these via my .zshrc
as so:
# ...snip
# ZSH autoloads functions from an FPATH env var, but this var is derived from an array called
# fpath.
# If the .zfunc dir exists and is not already in the `fpath` array, add it as the first element.
# Then call autoload on all the filenames in the directory
# (those shell expansion parameters are ZSH specific and they pull just the file names from non-dirs in said directory)
[[ ${fpath[(Ie)"$HOME/.zfunc"]} -gt ${#fpath} ]] || fpath=("$HOME/.zfunc" $fpath) && autoload ${fpath[1]}/*(:t)
# I have separate similar lines for the subdirectories.
# ...snip
In migrating to fish
, I'm now able to reuse these existing function definitions to hold me over by using pisciform
in my
fish configs. I'm using fish's configuration snippets feature, so I have
a 04-pisciform.fish
file in my ~/.config/fish/conf.d
directory that looks like this:
function __wrap_zsh_autoload
set -f funcdir $argv[1]
set -f args --zsh --autoload
if status is-interactive
set -f -a args --interactive
end
for funcfile in $funcdir/*
if test -f "$funcfile"
pisciform $args "$funcfile"
else if test -d "$funcfile"
__wrap_zsh_autoload "$funcfile"
end
end
end
__wrap_zsh_autoload $HOME/.zfunc
In a nutshell, it dynamically adds a function to the current fish
session that wraps the target function in a different shell environment.
Then, when you subsequently call the fish
version of the function, the following happens:
- A script called a "runner" is executed as an argument to the appropriate shell (
bash
/zsh
/sh
) - The runner script will create a tempdir to capture the information it needs
- The runner will source any init files that were passed to the wrapping call
- The runner will capture the existing environment variables and alias definitions for the subshell
- The runner will reset the directory stack so that only the changes from the function are captured.
- This only applies to Bash and ZSH, since POSIX doesn't have a concept of a directory stack and doesn't have
pushd
/popd
commands.
- This only applies to Bash and ZSH, since POSIX doesn't have a concept of a directory stack and doesn't have
- The runner will call the wrapped function or built-in
- The runner will exit early with the command's status if the status is non-zero
- The runner will capture the environment variables, aliases, and directory stack state from after the command is executed
- The runner will compute the following deltas and report them to the fish environment:
- Environment variables that no longer exist after the command has run; these variables will be erased with
set -e
in the fish environment - Environment variable "upserts"; that is, variables that are new as well as variables that are changed.
- Variables that are part of an
export
declaration will be exported in the callingfish
environment withset -gx
- Variables that are not exported will be set in the calling
fish
environment withset -g
- Variables that are part of an
- Aliases that exist after the script was run but did not exist before it was run will be created with
alias
- Environment variables that no longer exist after the command has run; these variables will be erased with
- The fish environment will
pushd
the reversed directory stack from the function execution; in other words, it will start from the bottom so that it ends up with the same final stack order as the subshell had when the function call completed.- This only applies to Bash and ZSH, since POSIX doesn't have a concept of a directory stack and doesn't have
pushd
/popd
commands. - If the element on the bottom of the stack is the same as the directory the function was originally called from, it'll get skipped.
- This only applies to Bash and ZSH, since POSIX doesn't have a concept of a directory stack and doesn't have
- If the final directory from the directory stack following is not the same as the final value of the
PWD
environment variable, we'llcd
in to the value that's in the ending version ofPWD
These values are all captured in a temporary directory created using mktemp -d
. The wrapper function will clean up the tmpdir
after execution.
Pisciform is heavily inspired by bass
and replay
, with the same basic philosophy: Use the original shell to execute the command, and play back the changes in the calling fish
shell. But it does a few things differently:
- Rather than using
pisciform
to execute the desired command, you runpisciform
to create a function that mirrors the wrapped function. So if you have a ZSH functionfoo
, and you usepisciform foo
, you'll end up with a fish function called foo that you can invoke whenever you want, with whatever arguments you want. pisciform
is a bit more deliberate in capturing environment changes; sometimes shell setup for the foreign shell might create environment variables or aliases that weren't created by the command itself invoked.bass
andreplay
would pull those in to the fish environment.pisciform
attempts to only pull in changes that are a direct result of the command you runpisciform
will also mirror changes to the directory stack, not just the PWD.pisciform
works with interactive commandspisciform
can be told that a function should be run in an interactive and/or login subshell, allowing it to utilize the profile and shellrc files in place for the subshell, which can be helpful when initially migrating tofish
.- Both
bass
andreplay
only supportbash
as the target for running non-fish shell commands.pisciform
supports POSIX and ZSH dialects as well.
Right now, ZSH is the primary target; it's where I was coming from, and a lot of care was taken to be able to load functions defined as bare bodies in files, the same way one would do with autoload in ZSH. Bash and SH support shouldn't take much longer to do, as most stuff should be pretty similar.
- Execute function calls defined in a ZSH-compatible way
- Support ZSH autoload function files
- Execute function calls defined in a BASH-compatible way
- Execute function calls in POSIX
/bin/sh
- Support
interactive
subshells - Support
login
subshells - Provide a list of files to be
source
'd before calling the function - Support shell built-ins as well as functions for implemented shells
- Support replaying environment variable add/delete/modification for supported shells
- Support aliases added in supported shells
- Support directory changes in supported shells
- Support replaying changes to the directory stack in supported shells
- Immediate execution of commands instead of generating function calls
- Immediately invoke a function as a one-off, replaying changes but not creating a Fish function
- Support a
source
mode that sources a file instead of trying to execute a command (different from existing init file support)
- Investigate "shell daemons" to avoid forking overhead
- Would need to be able to "reset" shell environments in between command invocations