Skip to content

shed Scripting Features

pagedmov edited this page Sep 24, 2026 · 7 revisions

shed exposes many unique scripting primitives as extensions to the usual POSIX shell syntax. These primitives are designed to make it easier to write scripts that are both powerful and easy to read.

This page assumes a certain level of familiarity with POSIX shell scripting, stuff like pipelines and compound commands won't be outlined here. This page specifically documents shed extensions.

The try Block

The try block runs a given command with set -e and set -o pipefail temporarily forced on. If anything inside the given command fails, control is given to the catch arm. The catch arm can contain an error message to print, and it can be given a body of commands to execute. It can also be given none of these things, in which case nothing happens on error. Below are some examples:

try
  curl -fsSl "$url" > artifact.tar.gz
  tar -xf artifact.tar.gz
catch "failed to fetch and extract artifact"

In this case, the catch arm will print the error message and set $? to 0.

try
  curl -fsSl "$url" > artifact.tar.gz
  tar -xf artifact.tar.gz
catch "failed to fetch and extract artifact"; do
  rm -f artifact.tar.gz
  exit 1
done

In this case, the catch arm will print the error message, remove the artifact file, and exit with a non-zero status.

try
  curl -fsSl "$url" > artifact.tar.gz
  tar -xf artifact.tar.gz
catch; do
  rm -f artifact.tar.gz
  exit 1
done

In this case, the catch arm will remove the artifact file without printing an error message, and exit with a non-zero status.

try
  curl -fsSl "$url" > artifact.tar.gz
  tar -xf artifact.tar.gz
catch

In this case, the catch arm will do nothing, and the script will continue executing after the try block. This can be used in scripts that have set -e enabled to create "safe zones" where errors won't blow up the script, so you can avoid having to spam || true after every command.

The defer Block

The defer keyword allows you to register a command to be executed once a scope is exited. Deferred commands fire in LIFO order (most recently registered first) regardless of whether the scope exits normally, via return, break/continue, exit, etc. It will also make a best-effort attempt at firing even in the event of a forced close, via signal or something similar.

This allows you to manage the lifetime of resources in a more structured way, similar to how defer works in languages like Go. For example, you can use defer to ensure that temporary files are cleaned up, file descriptors are closed, locks are released, etc. even if an error occurs.

Basic Usage

The body is a single command. For simple cleanup, that command is usually all that's needed:

tmpdir=$(mktemp -d)
defer rm -rf "$tmpdir"

# ... use $tmpdir ...

exit 0 # The deferred command will be executed here, before the script exits.

For multi-statement cleanup, you can run defer multiple times, or group commands in a brace group like this:

tmpdir=$(mktemp -d)
defer {
  rm -rf "$tmpdir"
  echo "Cleaned up temporary directory"
}

Scoping

In other shells, scopes are typically limited to function boundaries, but shed has more fine-grained scoping logic for certain things such as defer. The following are scopes that can hold a stack of deferred commands:

Kind ExitCondition
Function bodies Fires on return from the function
Brace groups ({...}) Fires on exit from the brace group
Compound command bodies (if,for...) Fires on exit from the compound command body
The shell itself Fires on exit from the shell

For loops (for, while, until), each iteration of the body is its own scope, so a defer registered inside the loop fires at the end of that iteration rather than accumulating until the loop finishes.

Exiting from a deferred command

Calling exit from inside a deferred command is a deliberate hard-stop: it terminates the shell immediately with the given status and does not run any remaining or outer-scope defers. This differs from a plain exit in normal code, which unwinds cleanly and does fire pending defers on the way out.

outer() { defer echo "outer cleanup"; inner; }
inner() { defer exit 3; echo working; }

outer      # prints "working", then exits 3. "outer cleanup" is skipped

defer is itself cleanup code, so an exit within it means "we're done, stop everything now." The exit status is still reported, and the shell's EXIT trap (if set) still runs.

Expansion

The registered command's arguments are expanded on registration, not on execution. This means that the deferred command holds the values that were live when defer ran, not the values at scope exit.

x=before
defer echo "$x"
x=after
exit 0 # prints "before"

The same applies to command substitution ($(...)):

defer echo "$(date +%s)"

The timestamp will be evaluated when the defer command is run, not when the scope exits.

If you wish to defer a command that expands lazily at execution time, use eval with a single-quoted command string. Single quotes survive the freeze pass intact, and eval parses/expands its argument at execution time:

x=before
defer eval 'echo "$x"'
x=after
exit 0 # prints "after"

Error Handling

The deferred command runs in its own execution context. Failures within the body respect set -e at trigger time, but a failed defer body does not propagate out of the scope. Errors are reported to stderr but do not abort the surrounding execution.

If you wish for cleanup failure to be loud and fatal, that requires being explicit:

defer { cleanup || exit 1; }

Shell Quoted Records (SQR)

"Shell Quoted Records" is a data format that can be used to maintain the structure of tabular data in a way that survives shell word splitting, and allows for trivial parsing. The idea is to pack arbitrary strings into a format that can be decoded later, and shell-quoting happens to be a great format for round-tripping data. Strings with spaces or shell metacharacters get single quoted, and strings with control bytes (tab, newline, etc) get wrapped in ANSI-C quotes, with the control bytes replaced with their escaped representations (\n, \t, etc). The result is a data format that can be trivially deserialized using the shell's inherent quote removal and word splitting.

The main drivers of this format are the quote and unquote builtins:

quote

Encodes arguments as shell-quoted strings and prints the results to stdout. Output uses the minimal level of quoting needed for each argument: single quotes for whitespace and metachars like $, and ANSI-C quotes ($'...') for stuff that contains control characters like \t and \n. Plain text with none of these things is passed through verbatim.

unquote

Decodes shell-quoted arguments, performing quote removal and word splitting, then emits the unpacked data. Has a few different methods for emitting the fields:

  • -v {name} - join decoded fields with spaces and assign to scalar variable name
  • -a {name} - join decoded fields with spaces and assign to array variable name
  • -s {sep} - join decoded fields with sep and print to stdout
  • -0 - shorthand for -s $'\0', separates fields with a NUL character and prints to stdout

The -0 flag makes unquote a viable adapter for many common Unix tools that use NUL-separated records, such as find -print0, xargs -0, and sort -z.

SQR structure

The structure of SQR output is dead simple, with zero weird exceptions:

  1. fields are separated by spaces outside of quotes
  2. records are separated by newlines (\n)

This invariant holds for any arbitrary input. SQR allows for extremely composable data handling, for examples see the quoted-streams tag by running help quoted-streams in the repl.

Clone this wiki locally