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:
SHELLenv var if it's set - this is the user's explicit choice and always wins.- On Windows (when
SHELLis unset):pwsh.exe(PowerShell 7+), thenpowershell.exe(Windows PowerShell, ships with every Win10+), thencmd.exeas a last resort. - On Linux/macOS (when
SHELLis 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 | |
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, orstderr(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
- Named when ALL variables are
- 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.