Drop #784 (2026-09-07): What The Shell?

This, I declare; rura; mdfried

We’re back with some crunchy shell goodnes, starting with primer on declare (which I’ve been leaning more into of late) and two super handy new-ish cli tools.


This, I declare

I’m in the midst of re-doing the homelab and have been both reviewing old and writing new Bash scripts along the way. During this process, I’ve been revamping some longstanding personal idioms, and a new one I’m leaning into is being more deliberate about using one of modern Bash’s more useful features: declare.

As I am confident most Drop readers know, Bash treats every variable as a string. declare assigns attributes — integer, array, read-only, exported, name reference — that change how bash evaluates and protects the variable for the rest of its scope.

Plain assignment (name="John") and declare name="John" produce the same variable. The attribute matters only when you add a flag, and we’ll go over some (most) of of them, here, today.

Read-only: -r

declare -r API_URL="https://api.example.com"
API_URL="https://malicious.example.com" # bash: API_URL: readonly variable

Use -r for values that must not change during a script’s run: config paths, thresholds, anything a later line could clobber by mistake. There’s no way to unset -r once it’s set. Read-only is permanent for the life of the shell.

Integers: -i

declare -i counter=10
counter+=5 # 15, no $(( )) required

This is convenient, but it hides a failure mode. Assign a non-numeric string to an -i variable and bash does not error. It silently sets the variable to 0.

declare -i n="abc"
echo "$n" # 0

Any script that reads -i variables from user input or an external command needs a separate validation step. The zero is indistinguishable from a legitimate zero.

Arrays: -a and -A

-a declares an indexed array, -A an associative array (bash 4+).

declare -a fruits=("apple" "banana" "orange")
fruits[3]="grape"
echo "${#fruits[@]}" # 4
declare -A user
user[name]="Alice"
user[role]="developer"
echo "${!user[@]}" # name role

Indexed arrays in bash are sparse, not dense. Assign an element at index 10 on an otherwise empty array and ${#array[@]} reports 2, not 11, if only two positions hold values. Length counts set elements, not a range. Code that assumes indices run contiguous from 0 will misbehave on any array built with explicit positions.

Neither array attribute can be unset with +a or +A. Once a variable is an array, it stays an array for the rest of the scope.

Exported: -x

declare -x DATABASE_URL="postgresql://localhost/mydb"
python my_script.py # sees DATABASE_URL in its environment

declare -x and export do the same thing. Arrays do not survive export. A child process started from a shell with an exported array sees nothing. Only scalar and integer variables cross the fork.

Case-folding: -u and -l

declare -u shout="hello"
echo "$shout" # HELLO
declare -l quiet="HELLO"
echo "$quiet" # hello

Every assignment to a variable with this flag is folded, not just the first. This normalizes input (hostnames, flag values) without a separate tr call or parameter expansion on every read.

Removing an attribute: +

Most attributes unset with a leading + instead of -.

declare -i n=42
declare +i n
n="hello" # now legal; the integer constraint is gone

-a, -A, and -r are the exceptions. Read-only cannot be reversed in the current shell, and once a variable is an array it cannot become a scalar again.

Inspecting variables: -p

declare -p API_URL
# declare -r API_URL="https://api.example.com"

declare -p with no argument lists every variable in scope and its attributes. declare -p name narrows to one variable, and the same syntax doubles as an existence check:

if declare -p API_URL &>/dev/null; then
echo "set"
fi

I should also note that flags stack. declare -r -x -i MAX_WORKERS=4 creates a variable that is simultaneously read-only, exported, and integer-typed. Order among flags doesn’t matter. The final attribute set is the union of what you passed.

Just like I do in most other programming languages, I am putting declare blocks at the top of scripts with some deliberate patterns. Mark constants -r, numeric thresholds -i, and anything a subprocess needs -x. These attributes turn assumptions that might have lived only in comments (i.e., “this doesn’t change” or “this is always a number”) into constraints Bash enforces. So, a misassignment fails at the point of the mistake, not three functions downstream.

For short, throwaway variables in a one-liner or pipeline, plain assignment is still the right call. declare earns its place in scripts long enough, or reused enough, that the type discipline pays for the extra keystrokes.


rura

close up photo of steel pipes
Photo by Jeremiah Buchanan on Pexels.com

rura is a Rust terminal UI that builds shell pipelines interactively. You type a pipe like grep error | sort | uniq -c, and rura runs each stage as you edit it, shows the output live, and lets you inspect or diff any stage without retyping the whole command.

Anyone who builds a shell pipeline by trial and error knows the loop: type a command, run it, realize the third stage is wrong, press up-arrow, edit, rerun, repeat. rura replaces that loop with a single editable buffer and a live output pane. The pipeline stays on screen. Only the part you changed re-executes.

The implementation has some super nice design choices, one of which is caching. CachedPipelineRunner (src/shell/cached_runner.rs) keeps a positional cache of (command_string, output_bytes, duration) tuples, one per pipeline stage. On each run, it walks the new command’s stages against the cached ones in order. At the first stage where the command text no longer matches, it truncates the cache from that point forward and re-executes every stage from there. Each stage reads stdin from the previous stage’s fresh output. Stages before the mismatch come from cache, and no subprocess spawns. If you’re still using Windows, you’re somewhat out of luck (in so many ways), as there’s no caching in the Windows-specific rust configs.

Tomasz goes hard on tests for this side-project, and designed them in such a way that they inherently document the invalidation rules:

  • Shorten a pipeline: everything comes from cache, no execution at all.
  • Extend a pipeline: only the new tail stages execute.
  • Edit a middle stage: that stage and every stage after it invalidate, even if their command text is unchanged, because their input changed.
  • A failing stage never caches. It re-runs on the next keystroke. Stages before it stay cached.

The utility also supports partial and live execution. Two keys map to partial execution against the parsed subcommand list: Alt+\ runs the pipeline up to the subcommand under the cursor. Alt+| runs up to the subcommand before it. Both use RuraInput::command() to slice self.subcommands at the cursor’s stage index.

F11 and F12 layer live typing on top: “Live Until Cursor” and “Live Full” re-run their respective slice on every keystroke. A debouncer thread (src/debouncer.rs) resets its timer on each keystroke and fires the callback once input goes quiet. Both live modes gate behind a confirmation popup on first activation, since arbitrary shell commands start running as you type.

Another sign that Tomasz has an attention to detail is that the the pipe-splitter (src/rura_input.rs::split_command) is a hand-rolled state machine, not a regex split on |. It tracks single-quote, double-quote, and backslash-escape state so that jq '.. | .name' does not get sliced at the pipe inside the jq filter. The test suite confirms this against a real jq filter argument. Malformed input (unclosed quotes, empty stages between pipes) returns a ParseError instead of silently mis-splitting.

Alt+d toggles a diff view computed with the similar crate’s Patience algorithm (src/output_widget.rs). It line-diffs the current stage’s output against a base. The base defaults to raw stdin but can be pinned to any stage’s output with Alt+/. You can diff “what grep changed” instead of always diffing against the original input.

Tab completion shells out to your actual shell (bash -c "compgen ...", zsh -c "print -l ...", or fish -c "complete -C ..."). It uses whatever completions that shell has installed, rather than reimplementing completion logic. Vim-mode command editing is real modal editing via the modalkit crate, not a keybinding approximation. Presets are named, optionally single-key-bound commands persisted to a TOML file. They work for pipeline fragments you reach for often (jq -c '', a standard sort | uniq -c | sort -nr tail).

You can install it via Homebrew, an AUR package (binary or -git), .deb/.rpm downloads, Nix, or cargo install rura (the repo also has pre-built binaries in the releases).

If your daily work is to iterate on grep | jq | awk-style chains against log data, telemetry, or honeypot capture output, the caching model is super helpful. Editing stage four of a five-stage pipeline against a large capture file does not mean re-parsing gigabytes through the first three stages again. The cost of experimentation drops close to zero, which also changes how much you are willing to poke at the data before you commit to a final command.


mdfried

I write and read far too much markdown and usually end up in a text editor (Zed) to do markdown previews since none of the CLI-based ones have brought much joy to the experience. Thankfully, mdfried — a Rust-based terminal pager for markdown files — has come to the rescue.

It has a very (deliberately) narrow scope. Headers render as genuinely larger text, not bold or colored text pretending to be a header. On a terminal that supports the Kitty Text Sizing Protocol, mdfried scales the header glyphs natively. On terminals without that protocol, it falls back to rendering headers as raster images through cosmic-text. Either path produces the same visual result: a document with true typographic hierarchy in a grid of monospace cells.

The project splits into two crates. mdfrier (the parser, MIT-licensed and published separately) walks markdown with tree-sitter-md. It maps each node to a Span that carries content and a bitflag Modifier set, then wraps the result to a target column width. A Mapper trait controls the decorator symbols for links, blockquotes, list markers, and table borders. A consumer can swap plain ASCII for the box-drawing and emoji-adjacent symbols mdfried uses by default.

The wrapping step tracks link URLs across line breaks. A URL can split across a hard wrap. It must still resolve to one destination for the link-navigation mode.

mdfried (the binary, NOTE: GPL-3.0-or-later) adds the terminal layer on top of that parser. It negotiates image protocols through ratatui-image, renders SVGs through resvg, highlights syntax for over 100 languages through arborium, and renders Mermaid diagrams through a built-in renderer or an external mermaid-cli command. A separate what-terminal-font crate detects the terminal font family. The first-run font picker uses this to pre-select a match. Header images must render in a font that matches the surrounding cell font.

There are a few details that matter for anyone who reads markdown reports over SSH.

First, image handling is viewport-aware. Images are sliced by row and cropped when partially outside the visible area, so a screen full of embedded diagrams scrolls without a full-image repaint stall.

Second, mdfried can open remote sources directly: a raw URL, github:<owner>/<repo> for a repo default-branch README, and stdin for piped input. A url_transform_command config option runs a fetched page through an external command. For example, you can pipe a page through a reader-mode extractor and html2text before mdfried parses it as markdown.

Third, the --watch flag reloads on file change. This is the useful case for a report or runbook you edit in one pane and review rendered in another.

NOTE: macOS folks will have to sudo xattr -c on the release binary or built it locally to avoid Gatekeeper shenanigans.


FIN

Remember, you can follow and interact with the full text of The Daily Drop’s free posts on:

  • Mastodon via @dailydrop.hrbrmstr.dev@dailydrop.hrbrmstr.dev
  • Bluesky via <https://bsky.app/profile/dailydrop.hrbrmstr.dev.web.brid.gy>

☮️

Leave a Reply

Discover more from hrbrmstr's Daily Drop

Subscribe now to keep reading and get access to the full archive.

Continue reading