On this page

Migration guide

Migrating from traditional shells

Translate Bash, zsh, fish, and Nushell habits into Shoal's command/expression modes, typed values, outcomes, feeds, scopes, and structured workflows.

Status
Current syntax and semantic differences
For
Experienced shell users adopting Shoal
On this page
  1. The five biggest mental shifts
  2. Variables: values, not expansion
  3. Environment variables are a namespace
  4. Command substitution becomes value capture
  5. Quoting and argument boundaries
  6. Globs are typed values
  7. Pipelines become value transformations
  8. Replace grep, awk, and cut with fields/methods
  9. jq often becomes direct field access
  10. Feed values to processes explicitly
  11. Redirection remains familiar—but typed alternatives exist
  12. Conditions use values/outcomes
  13. set -e becomes statement/value intent
  14. Defaulting and missing data
  15. Arrays and maps are first-class
  16. Functions are values and command heads
  17. Modules replace sourced global mutation
  18. Local state uses scopes
  19. Background work and jobs
  20. Functions should not mutate global cwd
  21. Process substitution has no direct spelling
  22. Here-documents become strings or interpreter blocks
  23. Secrets should not be ordinary environment/string variables
  24. Tool version managers become Reef scopes
  25. Safer destructive scripts use plan + journal + undo
  26. Migrating Nushell habits
  27. Migrating fish habits
  28. Incremental migration strategy
    1. Phase 1: keep legacy blocks at the edge
    2. Phase 2: replace parsing
    3. Phase 3: replace state/flow
    4. Phase 4: add reproducibility/safety
    5. Phase 5: remove compatibility islands selectively
  29. Translation checklist

Shoal is not a Bash-compatible shell with nicer objects. It keeps command-line ergonomics, but replaces expansion and byte pipelines with expression evaluation and typed values. The fastest migration strategy is to preserve external commands at the edges and rewrite the data transformations between them.

The five biggest mental shifts🔗

  1. Variables are expression names, not textual substitutions.
  2. Parenthesized commands produce values; $() does not exist.
  3. There is no command-pipeline |; transform values with methods and use .feed(...) only at byte-process boundaries.
  4. External failure is an outcome; statement position raises it, value position lets you inspect it.
  5. sh { ... } is an explicit compatibility island, not the default parser.

Read The command/expression model after this guide for the formal rule.

Variables: values, not expansion🔗

Bash/zsh:

project=shoal
printf '%s\n' "$project"

Fish:

set project shoal
printf '%s\n' $project

Shoal:

let project = "shoal"
echo (project)

let is immutable. Use var for intentional reassignment:

var attempts = 0
attempts = attempts + 1

There is no $project, ${project}, $project[1], or implicit interpolation during word splitting. Embed an expression in a command argument with parentheses:

let path = path("./report.csv")
cp (path) ./archive/report.csv

Use quoted interpolation where supported by Shoal syntax as documented in Grammar reference, but prefer passing a value directly when the whole argument is one expression.

Environment variables are a namespace🔗

Bash/zsh:

export LOG_LEVEL=debug
printf '%s\n' "$HOME"
LOG_LEVEL=trace command

Shoal:

env.LOG_LEVEL = "debug"
echo (env.HOME)

with env: {LOG_LEVEL: "trace"} {
  command
}

Session environment mutation is explicit and shared within that evaluator session. Prefer with env: for reusable/local scopes.

Command substitution becomes value capture🔗

Bash/zsh:

count=$(find . -type f | wc -l)

Shoal, using a structured builtin:

let count = (ls .).where(.type == "file").len()

For an external command:

let result = (^printf '{"count":3}\n')
let count = result.out.count

Parentheses do not mean “capture trimmed stdout as a string.” They place the command in expression/value position and retain the typed result/outcome.

Quoting and argument boundaries🔗

Traditional shells use quoting partly to prevent word splitting/globbing:

file='report with spaces.csv'
cp -- "$file" archive/

Shoal passes one value as one argument:

let file = path("report with spaces.csv")
cp (file) ./archive/

There is no implicit split of a string on whitespace. If you have a list of arguments, use the language’s explicit collection/call forms rather than assembling one shell command string. Dynamic command name:

run("cp", "report with spaces.csv", "archive/")

Raw external argv still becomes OS strings at the process boundary. Typed functions/adapters can bind words to declared types first.

Globs are typed values🔗

Bash expands a glob before the command:

rm -- *.tmp

Shoal recognizes glob-shaped command words and can pass a typed glob to a builtin/adapted parameter:

rm *.tmp

An unmatched glob is not a magical literal fallback for destructive operations; Shoal reports no matches/error where the builtin requires targets. Use glob("pattern")/documented glob methods when building patterns dynamically, not string concatenation and hoped-for expansion.

Pipelines become value transformations🔗

Bash/zsh:

find . -type f -print0 \
  | xargs -0 stat ... \
  | sort -n \
  | tail -20

Shoal:

(ls .)
  .where(.type == "file")
  .sort_by(.size)
  .reverse()
  .take(20)

There is no command-pipeline |. Choose based on the thing flowing:

FlowShoal mechanism
Finite typed collection.map, .where, .sort_by, .group, .reduce, etc.
One finite value into process stdinvalue.feed(^command ...)
Live/asynchronous sequencestream combinators and bounded sinks
Named cross-session eventschannel("user.name")
Legacy byte pipelineexplicit sh { ... } block

Replace grep, awk, and cut with fields/methods🔗

Bash:

git status --porcelain | awk '$1 != "??" {print $2}'

With a structured adapter (schema depends on the command adapter):

(git status --short)
  .where(.status != "untracked")
  .map(.path)

The key is not the shorter spelling; it is that .path remains a path/string field even when filenames contain whitespace/newlines.

For raw text intentionally:

path("./service.log").lines
  .where(line => line.contains("ERROR"))

jq often becomes direct field access🔗

Bash:

curl -s https://example/api | jq -r '.items[] | select(.active) | .name'

Shoal HTTP namespace:

let response = http.get("https://example/api")
if response.ok {
  response.json.items.where(.active).map(.name)
} else {
  {error: "http_status", status: response.status, body: response.body}
}

Or retain an external tool boundary:

let response = (^curl -s https://example/api)
response.out.items.where(.active).map(.name)

If structured detection does not parse the payload safely, use json.parse(response.stdout) explicitly.

Feed values to processes explicitly🔗

Bash:

printf '%s\n' alpha beta | sort -r

Shoal:

["alpha", "beta"].feed(^sort -r)

Serialization is type-defined:

  • string: exact UTF-8, no newline added;
  • bytes: exact bytes;
  • list<str>: newline-separated with trailing newline;
  • record/table/other list: compact JSON;
  • path: not file content—use .read/.read_bytes.

Streams cannot currently feed incrementally. Bound and collect first:

tail(path("./input.log"), from_start: true)
  .take(100)
  .collect()
  .feed(^consumer)

Redirection remains familiar—but typed alternatives exist🔗

Shoal supports:

^tool > ./result.txt
^tool >> ./result.log
^tool < ./input.txt

For values:

json.stringify(data, pretty: true).save(path("./result.json"))
"next line\n".append(path("./result.log"))

Redirection/save can participate in journal undo for overwrites when prior bytes fit the cap. Creating a new file currently has no delete inverse.

Conditions use values/outcomes🔗

Bash:

if git diff --quiet; then
  echo clean
else
  echo dirty
fi

Shoal:

let diff = (^git diff --quiet)
if diff.ok {
  "clean"
} else {
  "dirty"
}

Short-circuit operators understand booleans/outcomes and return the deciding operand:

(^test -d .git) && "repository"
(^git diff --quiet) || "working tree changed"

For clarity in scripts, explicit .ok is often easier to maintain.

set -e becomes statement/value intent🔗

Bash set -e has context-sensitive exceptions that are famously subtle. Shoal’s central rule is:

  • failed final command in statement position raises;
  • a command captured in value position remains a non-ok outcome;
  • try/catch handles raised errors explicitly.

Abort on failure:

^critical-step
^next-step

Inspect an expected failure:

let probe = (^optional-check)
if not probe.ok { echo (probe.stderr) }

Handle a raised error:

try {
  ^critical-step
} catch err {
  {code: err.code, message: err.msg}
}

Do not translate set -euo pipefail literally; encode which failures are expected and which should propagate.

Defaulting and missing data🔗

Bash:

value=${VALUE:-default}

Environment in Shoal:

let value = env.VALUE ?? "default"

Nested optional data:

let city = response.json?.user?.address?.city ?? "unknown"

?? handles null, not arbitrary false/zero/empty unless the language operation explicitly returns null. Missing lexical names are errors, not empty strings.

Arrays and maps are first-class🔗

Bash arrays/associative arrays require special expansion syntax:

items=(alpha beta)
declare -A user=([name]=Ada [role]=admin)

Shoal:

let items = ["alpha", "beta"]
let user = {name: "Ada", role: "admin"}

items[0]
user.name
user.get("role", "guest")

Tables are lists of records with a column-oriented rendering/operations:

let users = [
  {name: "Ada", active: true},
  {name: "Lin", active: false},
]
users.where(.active).map(.name)

Functions are values and command heads🔗

Bash:

greet() { printf 'hello %s\n' "$1"; }

Shoal:

fn greet(name: str) {
  "hello " + name
}

greet Ada
greet("Ada")

The same definition participates in command-shaped and expression-shaped calls. Typed/default/variadic parameters replace positional $1/$@ conventions.

Modules replace sourced global mutation🔗

Bash:

. ./lib/deploy.sh
deploy production

Shoal:

use ./lib/deploy
deploy.run("production")

Only exported module members are bound under the module namespace, reducing accidental global collisions. Module code is still trusted code and may have effects.

Local state uses scopes🔗

Bash subshell:

( cd subdir && LOG=debug command )

Shoal:

with cwd: path("./subdir"), env: {LOG: "debug"} {
  command
}

with expresses intended ambient changes without depending on process-subshell text semantics.

Background work and jobs🔗

Bash/zsh:

long-command &
jobs
wait %1

Shoal language task:

let task = spawn { long-command }
task.is_done()
task.await()

Trailing ampersand also exists:

sleep 30s &
jobs

The interactive Unix host has fg/bg process-group behavior. Kernel tasks are a different surface; raw kernel suspend/resume is currently unavailable.

Functions should not mutate global cwd🔗

Shoal disallows top-level session cwd commands inside function bodies. Instead:

fn test_crate(dir: path) {
  with cwd: dir {
    cargo test
  }
}

This makes a reusable function’s ambient dependency visible.

Process substitution has no direct spelling🔗

Bash <(command)/>(command) exposes pipes as pseudo-files. Shoal currently has no direct process-substitution construct. Choose:

  • keep the result as a value and transform it;
  • .feed it to stdin;
  • save to an explicit temporary/file path with lifecycle you control;
  • use a small sh { ... } compatibility block when a program strictly requires multiple pseudo-files.

Do not invent /dev/fd assumptions in portable Shoal scripts.

Here-documents become strings or interpreter blocks🔗

Bash:

python3 <<'PY'
print("hello")
PY

Shoal:

python3 {
print("hello")
}

For data stdin:

"alpha\nbeta\n".feed(^consumer)

Interpreter blocks preserve program source separately from fed data when the interpreter supports an inline program argument.

Secrets should not be ordinary environment/string variables🔗

Traditional shell:

export TOKEN=...
curl -H "Authorization: $TOKEN" ...

Shoal:

printf %s "$TOKEN" | shoal-secret set api-token
let token = secret.get("api-token")
http.get(url, headers: {Authorization: token})

Typed redaction reduces accidental display/journal encoding. Once passed to an external program, the program can still expose it; this is not a remote vault.

Tool version managers become Reef scopes🔗

Instead of implicit shell activation hooks mutating PATH:

# .reef.toml
[tools]
node = "22"
jq = "1"
reef lock
which node

Reef synthesizes child PATH from locked bindings. There are no arbitrary activation hooks. Hermetic Reef removes ambient PATH tail but does not replace Leash/OS sandboxing.

Safer destructive scripts use plan + journal + undo🔗

Traditional shell often uses set -x, dry-run flags, and backups. Shoal adds structured layers:

plan {
  mkdir --parents ./archive
  cp ./report.csv ./archive/report.csv
  rm ./report.csv
}

After actual execution:

journal --limit=10
undo out[3]

Only typed inverses are reversible. Network/opaque external work and permanent deletion are not rolled back.

Migrating Nushell habits🔗

Shoal and Nushell both value structured data, but their grammar/execution model differs:

Nushell habitShoal equivalent/difference
Pipeline carries valuesNo `
$name variablesPlain lexical name.
where/select commands.where(...) and .map(...)/field access.
External command ^cmdShoal also uses ^ to force external/skip adapter; exact precedence differs.
Error/status modelExternal returns Shoal outcome; statement/value position controls raising.
Environmentenv.NAME / scoped with env:.
Plugin ecosystemShoal adapters describe schemas/effects; not Nushell plugin protocol.

Do not mechanically translate Nushell pipelines to a fake Shoal pipe. Bind the command result and chain methods.

Migrating fish habits🔗

Fish users will recognize friendly command syntax and non-POSIX function ideas, but:

  • variables do not use $ at read sites;
  • lists are typed values, not fish’s list-expansion behavior;
  • and/or semantics operate on bools/outcomes, not only $status;
  • command substitution is value capture, not newline splitting;
  • scopes use let/var/function/module rules, not fish’s set -l/-g/-x flags;
  • functions can be called in expression form and return structured values.

Incremental migration strategy🔗

Phase 1: keep legacy blocks at the edge🔗

let raw = (sh {
  legacy-command | awk '...'
})

Inspect raw.ok, raw.stdout, and raw.stderr. Do not pretend the block’s internal effects are visible/reversible.

Phase 2: replace parsing🔗

Replace grep/awk/jq stages with an adapter/native structured result and methods. This yields the biggest correctness improvement for the least external-tool change.

Phase 3: replace state/flow🔗

Move shell variables to let/var, $() to captured values, conditional status checks to outcomes, and subshell cwd/env to with.

Phase 4: add reproducibility/safety🔗

Declare Reef tools, commit locks, add plan/effect review for mutation, enable journal/undo where supported, and write conformance/integration tests.

Phase 5: remove compatibility islands selectively🔗

Some specialized POSIX snippets are clearer left inside an explicit sh block. The goal is not zero shell text; it is making data, failure, state, and effects explicit where correctness matters.

Translation checklist🔗

For each legacy script, identify:

  1. Which commands are external boundaries?
  2. Which pipelines parse text that can become typed data?
  3. Which failures are expected probes versus abort conditions?
  4. Which variables are lexical values versus environment?
  5. Which implicit cwd/env changes need with?
  6. Which globs/word-splitting assumptions are load-bearing?
  7. Which operations mutate and can be planned/journaled/undone?
  8. Which tool versions need Reef locks?
  9. Which secrets should become typed secret references?
  10. Which legacy fragments should remain explicit sh blocks?

Continue with Recipes, External commands, and Outcomes and errors.

Type to search every guide navigate open esc close
Diagram