Skip to content

Shell Commands

The shell offers a wide range of utilities and is essential for CLI scripting - from file operations to invoking installed programs like git, make, or docker.

Rad has rich built-in functionality (http_get, read_file, write_file, etc.), but sometimes you need to invoke system tools or installed programs. Rad makes this ergonomic through first-class shell command support, and quotes the values you interpolate so a filename with a space - or a string from somewhere you don't control - can't turn into shell syntax.

Invoking Commands

Shell commands are invoked by prefixing a string with $:

$`ls -la`

You can also pre-define the command as a string variable:

cmd = `ls -la`
$cmd

By default, the stdout/stderr will be printed directly to the user's terminal as if they had invoked it directly themselves.

Prefer backticks for shell command strings

Shell commands often use 'single' and "double" quotes, so backticks minimize delimiter conflicts. However, you can use any string delimiter.

Which Shell Runs My Command?

Rad picks a shell to run your command in this order:

  1. SHELL env var if it's set - this is the user's explicit choice and always wins.
  2. On Windows (when SHELL is unset): pwsh.exe (PowerShell 7+), then powershell.exe (Windows PowerShell, ships with every Win10+), then cmd.exe as a last resort.
  3. On Linux/macOS (when SHELL is unset): /bin/sh.

Capturing Output

Shell commands produce three things: stdout, stderr, and an exit code. You can capture anywhere from zero to all three, depending on what you need.

Capture Modes

There are four levels of capture:

1. No capture - output goes to terminal

When you don't assign any variables, all output goes to the terminal:

$`ls -la`

2. Capture stdout

Assign to one variable to capture the command's output:

stdout = $`git show 0dd21e6`

Stdout is captured as a str. Stderr still goes to the terminal. Important: When you capture a stream, it stops printing to the terminal - it's redirected to your variable.

3. Capture stdout + stderr

Assign to two variables to capture both streams:

stdout, stderr = $`npm install`

Both are captured as str. Nothing the command prints reaches the terminal.

4. Capture everything, including the exit code

Assign to three variables:

stdout, stderr, code = $`npm install`

The exit code is an int. It needs no capturing, so it comes last - and you often don't need it at all, because a non-zero exit already raises an error you handle with catch: (see Error Handling below).

Named Assignment

Rad supports a special form of assignment when working with shell commands. When all your variables are named exactly code, stdout, or stderr, then assignment happens by name rather than by position. This means the order doesn't matter:

// Named assignment - order independent
stdout, code = $`echo hi`           // code=0, stdout="hi\n"
stderr = $`bad-command`             // Just capture stderr
code, stderr = $`make format`       // code=1, stderr=""
stderr, stdout, code = $`ls`        // All three, any order

This improves readability - you can capture exactly what you need with clear, self-documenting variable names. It's also the only way to capture the exit code without also capturing the streams: code = $cmd leaves stdout and stderr going to the terminal.

The rule: If ALL variables use exactly code, stdout, or stderr, assignment is by name. Otherwise, it's positional:

// Positional - neither name is reserved, so slots decide
out, err = $`echo hi`               // out = stdout, err = stderr
result, errors, status = $`ls`      // Assigned in (stdout, stderr, code) order

Note that the two rules agree as long as you keep to the canonical order - stdout = $cmd and stdout, stderr = $cmd mean the same thing whether Rad reads them by name or by position.

Don't mix reserved and unreserved names

One unreserved name drops the whole statement to positional, so a reserved name in the wrong slot silently gets something else:

code, output = $`echo hi`       // 'code' gets stdout!

Rad warns about this (RAD40018). Name them all, or name none.

Silencing outputs

Use _ to discard a stream you don't want printed: _ = $cmd swallows stdout, and _, _ = $cmd swallows both streams for fully silent execution.

Error Handling

Now that you understand how to capture output, let's talk about error handling.

When a shell command exits with a non-zero exit code, it triggers error propagation - just like functions that return errors. This means you can handle potential failures using catch: blocks:

// Handle errors with catch block
$`make build` catch:
    print_err("Build failed!".red())
    exit(1)

// Or ignore failures
$`make build` catch:
    pass  // Continue on failure

You can combine capturing with error handling. When the catch: block runs, your variables are already assigned their actual values, so you can inspect them:

// Capture the exit code AND handle errors
code = $`make test` catch:
    print_err("Command failed to run. Error code {code}")
    exit(1)

print("Tests passed!")

This works with any capture pattern:

code, stdout = $`git tag --list` catch:
    print_err("Failed to get tags")
    exit(1)

version = stdout.trim()

This uses the same error model covered in Error Handling - errors propagate by default, so you need catch: blocks to handle them.

String Interpolation

The text you write in a shell command is shell - pipes, redirects, &&, all of it. Interpolations are data. Each one becomes exactly one argument, whatever it contains:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
args:
    version str
    message str

$`git tag v{version}` catch:
    print_err("Failed to create tag")
    exit(1)

$`git commit -m {message}` catch:
    print_err("Commit failed")
    exit(1)

Note that {message} has no quotes around it. Rad quotes it for you, at the point it knows what the value is. A message of it's fine reaches git as it's fine; one containing $HOME, * or ; arrives with those characters intact rather than being expanded or run.

Don't add your own quotes

Writing $`git commit -m "{message}"` puts the quote characters into the message. Rad flags this as RAD40023. If your argument is literal text plus a value, build the string first:

version = "1.2.3"

message = "Bump to {version}"
$`git commit -m {message}`

Because the shell joins adjacent quoted fragments, values glue onto neighbouring text without any help:

name = "My Notes.txt"
version = "1.2.3"

$`cp {name} backup/{name}`      // two arguments, spaces and all
$`docker build -t app:{version} .`

Lists Become Several Arguments

A list expands to one argument per element, each quoted separately. An empty list contributes nothing, which makes conditional flags straightforward:

args:
    all bool

flags = all ? ["--hidden", "--no-ignore"] : []
needle = "two words"

$`rg {flags} -- {needle}` catch:
    pass

A list has to stand alone as its own argument - --file={files} is an error, because there is no obvious answer to which element gets the prefix. Join it yourself when you want a single argument:

files = ["a.txt", "b.txt"]

$`tar -czf out.tgz {files}`          // three arguments
$`echo --files={files.join(",")}`    // one argument

Commands As Lists

When a command is a list rather than a string, Rad runs the program directly and skips the shell entirely. Each element is one argument:

message = "it's fine"

$["git", "commit", "-m", message]

This is the form to reach for when you're assembling a command piece by piece. Nothing needs quoting, because nothing is ever parsed as shell:

args:
    start str?
    end str?

path = "clip.mp4"

cmd = ["ffmpeg", "-i", path]
if start:
    cmd += ["-ss", start]
if end:
    cmd += ["-to", end]
cmd += ["-c", "copy", "out.mp4"]

$cmd catch:
    print_err("ffmpeg failed")
    exit(1)

The trade-off is that you give up everything the shell provides: no pipes, no redirects, no globs, no $VAR expansion, and no shell builtins such as cd. Use a command string when you need those, and a list when you don't.

A command that is already a string runs verbatim

$cmd where cmd is a string is the raw form: you assembled the text, so you own its quoting. Rad has no interpolation to protect at that point - the interpolation already happened when the string was built. Prefer a list.

Modifiers

Rad provides two modifiers that can be applied to shell commands.

The quiet Modifier

By default, Rad announces each shell command with a ⚡️ prefix. For example, this command:

$`touch hello.txt` catch:
    print_err("Failed to create file")
    exit(1)

Shows in the terminal:

⚡️ touch hello.txt

To suppress this announcement, use the quiet modifier:

quiet $`touch hello.txt` catch:
    print_err("Failed to create file")
    exit(1)
(no output - unless there's an error)

This is useful for scripts that run many commands or when you want minimal output.

The confirm Modifier

The confirm modifier prompts the user before running a command:

confirm $`rm -rf node_modules`

This is particularly useful for destructive operations.

Practical Examples

Let's look at some real-world patterns that combine these features.

Development Workflow

Here's a script inspired by a typical development workflow:

---
Validates code, checks git status, and optionally pushes changes.
---
args:
    push p bool  # Push changes after validation

// Run validation steps
steps = ["go mod tidy", "make format", "make build", "make test"]

for step in steps:
    $step catch:
        print_err("❌ {step} failed".red())
        exit(1)
    print("✅ {step} passed".green())

if push:
    // Check for uncommitted changes
    stdout = $`git status --porcelain` catch:
        print_err("Failed to check git status")
        exit(1)

    if stdout.trim() != "":
        print_err("Working directory has uncommitted changes!")
        print_err("Commit your changes before pushing.")
        exit(1)

    // Get current branch and push
    stdout = $`git branch --show-current` catch:
        print_err("Failed to get current branch")
        exit(1)

    branch = stdout.trim()
    print("Pushing to {branch}...".yellow())

    $`git push origin {branch}` catch:
        print_err("Push failed")
        exit(1)

    print("✅ Pushed to {branch}".green())

print("✅ Done!".green())

Conditional Construction

Building commands dynamically based on script arguments. Collect the arguments in a list so each one stays a single argument no matter what it contains:

args:
    verbose v bool
    output o str?

cmd = ["docker", "build", "."]

if verbose:
    cmd += ["--progress=plain"]

if output:
    cmd += ["-t", output]

$cmd catch:
    print_err("Docker build failed")
    exit(1)

print("Docker image built successfully".green())

Checking Prerequisites

Verifying that required tools are installed:

tools = ["git", "docker", "make"]

for tool in tools:
    _, _ = $`which {tool}` catch:
        print_err("Required tool not found: {tool}")
        print_err("Please install {tool} before running this script")
        exit(1)

print("All prerequisites installed ✅".green())

Summary

  • Shell commands use the $ prefix and follow the same error model as functions
  • Error handling: Non-zero exit codes propagate errors unless handled with catch: blocks
  • Capture modes:
    • None: output goes to terminal
    • Stdout: stdout = $cmd (stderr to terminal)
    • Stdout + stderr: stdout, stderr = $cmd (nothing to terminal)
    • All three: stdout, stderr, code = $cmd
  • Assignment semantics:
    • Named when ALL variables are code, stdout, or stderr (order-independent)
    • Positional otherwise, filling (stdout, stderr, code) in that order
    • Mixing the two is the one trap: one unreserved name makes the whole statement positional
  • Output routing: Captured values don't print to the terminal (they're redirected to variables)
  • The exit code comes last because you rarely need it - a non-zero exit already raises an error
  • Command forms:
    • $`text {value}`: the text is shell, each interpolation is one argument, quoted for you
    • $list: an argument vector, run directly with no shell involved
    • $str: a string you assembled yourself, run verbatim
  • Interpolate a list to get one argument per element; an empty list contributes none
  • Don't put your own quotes around an interpolation - Rad already quotes it
  • Backticks are preferred for shell command strings to avoid delimiter conflicts

Next

Shell commands let you invoke external programs, but what if you want to organize your script into multiple operations - like git commit, docker build, or kubectl apply?

That's where commands come in. We'll explore them in the next section: Script Commands.