In my previous blog post on Nushell, I showed how to make quick but well-formed command line tools. I also provided a sample of using Nushell’s module system to create task specific command line environments with custom commands. This post continues to explore the idea of specialized working environments by introducing a way of managing such tools.
It’s rarely just one tool
When a task becomes repetitive, complex or laborious enough, it’s natural to seek a tool to make it easier. Sometimes the tool already exists, sometimes you must make one yourself (like a Nushell module).
Over time, the number of tools required to keep task complexity manageable tends to increase. Initially, tools for managing the development environment are enough. After the first deployment to a real environment or two, tools for managing them become useful. Yet more tools are needed for debugging and monitoring.
Some tools (ex. log viewers) are always needed visible, some (ex. build tools) you invoke as needed. Some tools have interactive UIs and some just do their thing in the background and you only need to view them when something’s wrong.
Even starting up all these tools becomes a laborious task. Launch containers, attach log viewers, start compilers in watch mode, start editor, edit stuff, rebuild containers, search shell history for the magic incantation for deploying to the cloud so you don’t make typos, check cloud status and so on. One option is to make a script to launch all that but then there would be a ton of windows to manage before getting started. Those could be organized with a script as well, but this is quickly getting complex enough that the effort of making the script becomes too high.
Zellij layouts
Zellij is a terminal multiplexer like the venerable Screen or its younger sibling Tmux, which allow running multiple command line programs concurrently in separate configurable panes. For just regular terminal multiplexing Zellij is just a bit different but not exceptionally better than Tmux. However, it does have a feature that makes it particularly well suited to solving the issue of managing task specific tools: layout files.
Layout files look like the default one below and simply define all the tabs and panes Zellij launches with when they are used. They also allow setting custom key bindings and other settings on startup.
layout {
// Zellij's default tab bar
pane size=1 borderless=true {
plugin location="zellij:tab-bar"
}
// A shell
pane
// Zellij's default status bar
pane size=2 borderless=true {
plugin location="zellij:status-bar"
}
}
The idea is to organize all the tools needed for a task into one layout file. Then starting work on a project requires only launching Zellij with the project specific layout. I defer to Zellij’s excellent layout tutorial on how to make layout files, no need to repeat all that here.
There are probably some commands that are needed regularly during a task you would make a layout file for. For me, one is rebuilding containers. Doing it is so fast that there’s usually no need for a separate auto-reloading developer mode (like “watch” mode in some build tools or separate development server mode in some frameworks), but the build command is long enough that it is usually fastest to search the shell history for it. It’s a tiny bit of time but an unnecessary papercut. Instead, I now do custom keybindings in layout files to do these things.
layout {
pane size=1 borderless=true {
plugin location="zellij:tab-bar"
}
// Launch $EDITOR (helix in my case) for current directory
pane edit="." borderless=true
pane size=1 borderless=true {
plugin location="zellij:status-bar"
}
}
// Bind Ctrl-R to rebuilding docker containers
keybinds {
shared {
bind "Ctrl r" { Run "docker-compose" "up" "-d" "--build"; }
}
}
Extending Zellij layouts with a Nushell script
Zellij’s layout file format doesn’t allow for any complex logic in the commands being run. This is a hindrance, because often the mini tasks needed may consist of several steps, such as getting a list of running containers and sending a command to a specific one. They may also be combinations of commands like watching a directory for changes and running a thing when something changes.
Instead of littering the layout file with all this, it is simpler to make a script for handling the logic. I picked Nushell for this because it is super straightforward for making nicely typed command line tools. I don’t want some typo making invisible mistakes every time I launch a layout, so the added certainty is welcome.
My convention is to have a zellij.kdl for the layout definition and zellij.nu for the layout script. Inside the layout definition I add a pane template called “cmd” for calling the layout script called cmd like this:
pane_template name="cmd" command="./zellij.nu"
This way it is easy to add panes that call functions in that script. My convention is to have a “pane” subcommand for rendering contents for different panes. Here’s an example with a few:
zellij.kdl:
layout {
tab name="dev" focus=true {
pane size=1 borderless=true {
plugin location="zellij:tab-bar"
}
pane split_direction="vertical" {
pane name="helix" focus=true borderless=true size="50%" edit="."
pane {
cmd name="lint"{ args "pane" "lint"; }
cmd name="log" { args "pane" "log"; }
}
}
pane size=1 borderless=true {
plugin location="zellij:status-bar"
}
}
pane_template name="cmd" command="./zellij.nu"
}
keybinds {
shared {
bind "Ctrl r" { Run "nu" "zellij.nu" "rebuild"; }
}
}
zellij.nu:
#! /usr/bin/env nu
def main [] {
}
def "main pane log" [] {
# Show project container logs with lnav
cd docker
docker-compose logs -f | lnav
}
def "main rebuild" [] {
cd docker
docker-compose up -d --build
}
def "main pane lint" [] {
# Clear between ruff lints to provide a clean view
watch ckan --glob "**/*.py" {|| clear; ruff check }
}
Some commands may need to be run after making changes. Some of them already have a watch mode, but for those that don’t Nushell has an excellent watch command. The same principle can also be used for automatically starting tools when you need them, like starting up a Svelte compiler in watch mode only when you modify a UI file.
Project CLI
You can probably see where this is going: simply make a new pane that spins up a Nushell instance and uses a project-specific module or two. Bam, instant project specific CLI every time you start working on it. Add some helpers there for running tests, deploying, calling APIs and whatever else you need every now and then.
def "main pane cli" [] {
nu -e "use example.nu; use local-env.nu"
}
For example, for one CKAN project deployed to AWS I have things like this in my project CLI:
# Requires following environment variables, usually set in environment-specific files
# like local-env.nu, dev-env.nu and prod-env.nu using suitable methods
# CKAN_URL: CKAN instance URL
# CKAN_KEY: CKAN API key/token
# AWS_PROFILE: AWS profile name
# Call current environment's CKAN action API
export def "ckan action" [path: string, --post: any] {
if $post != null {
http post -t application/json $"($env.CKAN_URL)/api/action/($path)" -H [Authorization $env.CKAN_KEY] $post
} else {
http get $"($env.CKAN_URL)/api/action/($path)" -H [Authorization $env.CKAN_KEY]
}
}
# Helper for calling AWS CLI using aws-vault, handles authentication and response parsing
export def aws [params: list<any>, --raw] {
if $raw {
aws-vault exec $env.AWS_PROFILE -- env aws ...$params
} else {
aws-vault exec $env.AWS_PROFILE -- env aws ...$params | from json
}
}
# Helper is used like this and composes nicely with other commands
export def "aws clusters" [] {
aws [ecs list-clusters] | get clusterArns | each {split row / | last}
}
# Compose to make more complex operations...
export def "aws tasks" [] {
aws clusters
| each {|cluster| aws [ecs list-tasks --cluster $cluster] | insert cluster $cluster | select cluster taskArns}
| flatten
| insert task {$in.taskArns | split row / | last}
| select cluster task
| insert description {|it| aws [ecs describe-tasks --cluster $it.cluster --task $it.task] | get tasks | first}
}
# And even more complex operations...
export def "aws attach" [filter: string] {
let task = aws tasks | where description.taskDefinitionArn =~ $filter | first
echo $"connecting to ($task.description.containers | first | get name) in ($task.cluster)"
aws --raw [ecs execute-command --command "/bin/bash" --cluster $task.cluster --task $task.task --interactive]
}
# How do you tunnel a local port 12345 to an ECS container's internal port 23456?
# Simple, just call `project-name aws tunnel container-name 12345 23456` in the project CLI
export def "aws tunnel" [filter: string, local_port: int, remote_port: int] {
let task = aws tasks | where description.taskDefinitionArn =~ $filter | first
let container = $task.description.containers | first
echo $"tunneling port ($local_port) to ($container.name) port ($remote_port) in ($task.cluster)"
aws --raw [ssm start-session --target $"ecs:($task.cluster)_($task.task)_($container.runtimeId)" --document-name AWS-StartPortForwardingSession --parameters $'{"portNumber":["($remote_port)"], "localPortNumber":["($local_port)"]}']
}
Then, there is no need to search my full command line history for specific instances of commands with the correct parameters or having to remember them. Instead, I can use the nice type checked auto-completed auto-documented Nushell interface.
Evolution, not revolution
None of what I have shown here is exactly new. Shell scripts have been used to simplify common tasks for at least 50 years. Tmux has allowed making split layouts for 16 years.
But Nushell and Zellij make it vastly easier to utilize those same concepts efficiently. Combined, they make turning a task-specific combination of tools and commands into an ad-hoc integrated development environment a quick and simple task.
It’s not that these tools make developing project specific tooling possible, but they make it low effort enough to quickly recoup the time spent on producing them.