shell-mode — shell-dispatch mode

Mix is both a scripting language and a login shell. When you hand it a line that looks like a command rather than Mix code — git status, ls -la | wc -l, make && ./run — it dispatches that line to the system the way a shell does: && / || / ; chaining, | pipes, brace expansion, $(...) command substitution, redirects. This is shell-dispatch mode, the shell half of the mix -c / login-shell contract.

The whole point: a node that runs /opt/cosmix/bin/mix as root's login shell must still honour the universal sh -c contract — ssh host 'hostname' and ssh host 'systemctl restart foo' have to just work, while print(1 + 1) and every other Mix statement still evaluate as Mix. One classifier decides per line which world you are in.

Since 0.31, ; also separates Mix statements on a line classified as Mix ($x = 1; print($x)). Classification is still whole-line: shell-dispatch gives the glyph the command-list semantics documented here; executable Mix gives it the hard statement-boundary semantics documented in syntax. It never switches languages midway. In particular, Mix permits &&/|| continuation across a physical newline, but ; always ends that chain.

One sentence to carry: shell-dispatch handles one command, a pipeline, or a &&/||/; chain with bash-style brace expansion, $(...), and redirects — it is not a full shell scripting language (no for/while/if shell constructs, no functions — those are Mix).

The two-context split is the thing to internalise: $(...) and {a,b} are LIVE on a shell-dispatch line but LITERAL inside a Mix string. See The two-context split below.

Where shell-dispatch happens

Interactive sessions can opt into the owned editor preview with MIX_EDITOR=owned. Rustyline remains the default; both editors feed the same shell-dispatch and evaluation paths.

Three entry points run the same classifier (shell::classify_input) and the same executor (exec.rs):

mix -c '<line>'         one-shot:  classify <line>, dispatch or evaluate
mix -i -c '<line>'      as -c, but load ~/.mixrc first (aliases + PATH)
<the REPL prompt>        every line you type interactively
ssh host '<line>'        if mix is root's login shell there, host runs it as -c

An ordinary .mix script remains whole-file Mix. The narrow exception is an explicitly continued command line: its physical lines are spliced first, then the complete logical line is classified through the same shell-first path. This makes echo one \ followed by two agree in a file, -c, and the REPL without turning every bareword in every script into a command.

A login-shell node makes the last one transparent — ssh host 'uptime' is evaluated by mix, dispatches uptime to the system, and returns its output and exit code. Send Mix source when you want Mix behaviour; send a bare command when you want shell dispatch.

Two other paths run commands but are not normally this classifier: the run() / run_rc() builtins always spawn /bin/sh -c (POSIX sh — no brace expansion there), and a .mix script file is parsed whole as Mix unless the explicit-command-continuation exception above applies. Backslashes inside a run/sh argument string stay untouched and belong to that real shell.

source first tries to parse the entire file as Mix, then falls back to classifying it line by line when that parse fails. An explicitly continued logical line classified as shell also forces this fallback, even if the half-joined text could have parsed as Mix arithmetic. Consequently, a file containing argument-less bareword command lists such as ls; pwd can parse as discarded Mix string expressions and do nothing; add command arguments to force the shell fallback, or write explicit Mix calls in a pure-Mix file. This is a known compatibility edge of whole-file-first sourcing.

The classifier — shell or Mix?

Each line is classified shell-first by its first word:

First word is…Goes to
a Mix keyword head (print, if, for, send, try, …)Mix (with a shell-chain fallback)
a $-led line ($x = 1)Mix (with a shell-chain fallback)
a shell builtin (cd, exit, which, mix, …)shell dispatch
a defined function invoked bareword (sc restart nginx)Mix — a command-style call sc("restart", "nginx")
a tight-hyphenated command head (cosmix-comp, systemd-nspawn)shell dispatch, even when missing from $PATH; a missing command reports its complete name
found on $PATH, or an absolute/relative pathshell dispatch
none of the above, but parses as a Mix expressionMix
none of the above and a command-like headshell dispatch (so you get the familiar No such file or directory, exit 127)

The exact keyword-head set routed to Mix: if for while loop function fn return select print eprint die try parse export alias break continue send address emit source sh label. Note what is not in it: true, false and nil (see the next section).

Bareword function dispatch — bash parity for your toolkit

A line whose head is a currently-defined Mix function dispatches as a command-style call, so a ported bash toolkit works with no parens:

$ sc restart nginx          -- runs sc("restart", "nginx")
$ health                    -- zero-arg call, no parens
$ newpw 20                  -- newpw("20")

The function check sits after Mix keywords and shell builtins but before the $PATH probe, so a defined function shadows a same-named binary — matching bash's alias → keyword → builtin → function → PATH order. Only a simple command qualifies: plain word arguments, quotes ('…' / "…") group and are stripped, and there is no variable or glob expansion — bareword args are literal (use the paren form sc("restart", $svc) when you need interpolation). Anything with a pipe, redirect, &&/||/; chain, $(…) substitution, or a KEY=val env prefix falls through to normal shell dispatch, so a function in a pipeline is still the shell's job. Inside a .mix script (the whole-file parse path) there is no shell classifier — call functions the Mix way, sc("restart", "nginx").

A function is "defined" only when it is in scope at classify time: interactive mix -i (which loads ~/.mixrc and the ~/.rc toolkit) and mix -i -c '…' see them; a bare mix -c '…' (no rc) does not, so there the same line stays a shell command.

A line the classifier hands to Mix that then fails to lex/parse reports the real lexer/parser error and exits 1 — mix -c 'print(0755)' prints the leading-zero lex error, not "No such file". A genuine shell typo (gti status) still gets mix: gti: No such file or directory and exit 127, and real commands keep dispatching.

The tight-hyphen rule applies only to a command-shaped statement head: an ASCII letter/_ start, at least one unspaced -, an ASCII letter/digit/_ end, and no other characters. It does not reach into print(a-b), assignments, or parenthesised expressions; a - b, $a-$b, $x-1, and 1-2 are subtraction. Scope does not affect the result: bare a is the string "a", not $a, so head a-b is a command even when $a is live. Exact earlier heads retain precedence, so mix not-a-command is still a mix meta-command invocation.

print(1 + 1)        -- Mix: prints 2
2
echo hi             -- shell dispatch: echo is on PATH
hi

Gotcha: a bare true / false runs the shell binary

Inside a Mix expression, true, false, and nil are literals ($x = false, if $a == nil, [true, false]). But as a bare line head true and false route to shell dispatch and run the /usr/bin/true / /usr/bin/false binaries — so false exits 1 like in every other shell, honouring the universal shell idiom:

$ mix -c 'false'; echo "rc=$?"
rc=1
$ mix -c 'true'; echo "rc=$?"
rc=0

This is deliberate: forcing a bare false to the Mix literal made it exit 0, which broke cmd || false-style scripts. nil has no external binary, so a bare nil still falls through to the Mix parse. In a &&/||/; chain they behave like the shell too:

$ mix -c 'true && echo chained'
chained
$ mix -c 'false || echo fallback'
fallback

Chaining — &&, ||, ; (shell-dispatch lines)

bash semantics, evaluated left to right:

$ mix -c 'echo one ; echo two'
one
two
$ mix -c 'mkdir -p /tmp/d && echo made'
made
$ mix -c 'test -f /nope || echo missing'
missing
  • ; — run unconditionally (sequence).
  • && — run only if the previous command succeeded (exit 0).
  • || — run only if the previous command failed (non-zero).

The chain's exit code is the last command actually executed. A skipped command carries the prior success/failure forward, exactly like bash.

A short-circuited branch is never expanded or run — a $(...) inside it has no side effect:

$ mix -c 'false && /bin/touch /tmp/pwn'
$ test -f /tmp/pwn && echo EXISTS || echo absent
absent

A single trailing ; is allowed (echo a ;). A leading or interior empty piece (&& echo x, echo a ;; echo b) is an error. (Note: a &&-led line is tried as Mix first, so mix -c '&& echo x' surfaces a Mix parse error, not the shell empty-piece error — start a chain with a real command.)

That empty-piece rule is shell-specific. On a line already classified as Mix, leading/trailing/repeated semicolons are harmless empty statements: ; print(1);; prints 1.

Pipes — |

$ mix -c 'printf "b\na\nc\n" | sort'
a
b
c
$ mix -c 'ls /etc | wc -l > /tmp/count.txt'

Each pipe segment is its own program; stdout of one feeds stdin of the next. The pipeline's exit code is its last segment's. Pipes bind tighter than the chain operators, so a | b && c | d is (a | b) && (c | d).

Background — trailing &

A & at the very end of a line backgrounds the whole pipeline and returns immediately (exit 0):

$ mix -c '/bin/sleep 5 &'; echo "rc=$?"
rc=0

A & that is not at the end is not a job separator (Mix has no mid-line & job control like bash). To background one command and continue, split with ;:

$ mix -c '/bin/sleep 5 & ; echo launched'
launched

In a one-shot mix -c, a backgrounded child is re-parented to init when the process exits. In the long-lived REPL it goes into the job table (jobs, fg, bg).

Brace expansion

bash-5.3 brace expansion, expanded before variable/$(...) resolution (like bash). Alternation {a,b,c} and sequences {x..y[..step]}:

$ mix -c 'echo file.{txt,md,rs}'
file.txt file.md file.rs
$ mix -c 'echo {1..5}'
1 2 3 4 5
$ mix -c 'echo {a..e}'
a b c d e
$ mix -c 'echo {1..10..2}'
1 3 5 7 9
$ mix -c 'echo {a,b{1,2}}'
a b1 b2

Sign-aware zero padding — an explicit leading zero on an endpoint pads every element to the widest endpoint's width:

$ mix -c 'echo {01..03}'
01 02 03
$ mix -c 'echo {-03..3..2}'
-03 -01 001 003

Other bash-5.3 rules that hold: nesting ({a,b{1,2}}), reverse ranges ({5..1}), the increment's absolute value is used and 0 means 1. A group that is not a valid brace expression stays literal:

$ mix -c 'echo {a}'
{a}
$ mix -c 'echo a{}b'
a{}b

So find . -exec {} \; is safe — the {} is invalid as a group and passes through untouched.

Hardening (never a partial expansion): a word expanding past ~10 000 results, or groups nested/chained past 64 deep, is left completely unexpanded rather than partially expanded. The injection invariant holds — braces are structural only in raw literal text: a quoted/escaped {, a ${X}, and anything a variable's value contains never expand.

$(...) command substitution

Runs the inner command via /bin/sh -c, splices its stdout into the line with trailing newlines stripped:

$ mix -c 'echo today is $(date +%A)'
today is Monday
$ mix -c 'echo got-$(echo X)'
got-X

Unlike bash, the result is ONE word — it is not re-split on whitespace. This is a deliberate safety difference:

$ mix -c 'echo [$(echo a b c)]'
[a b c]

(In bash, echo [$(echo a b c)] would re-split into three words. Mix keeps the captured output intact as a single argument — closer to a quoted "$(...)".)

Things that stay literal on a shell-dispatch line:

$ mix -c 'echo $((1+2))'          # arithmetic $(( )) is NOT command substitution
$((1+2))
$ mix -c "echo '\$(echo X)'"      # single-quoted is fully literal
$(echo X)

An inner | / ; / && belongs to the substitution, not the outer line: echo $(ls | wc -l) runs ls | wc -l inside the $(...) and splices the count. A lazy detail: a $(...) is run only when its chain piece is selected for execution (see the short-circuit example above).

Redirects

In parse order, like bash:

>  file     1> file    redirect stdout (truncate)
>> file     1>> file   redirect stdout (append)
<  file                redirect stdin
2> file                redirect stderr (truncate)
2>> file               redirect stderr (append)
2>&1                   fd 2 := fd 1   (stderr to wherever stdout points)
1>&2  / >&2            fd 1 := fd 2   (stdout to stderr; >&2 is the common idiom)
>&1                    1>&1 — stdout onto itself, a no-op
&> file     &>> file   both streams to file  (≡ >file 2>&1 / >>file 2>&1)
$ mix -c 'echo hi > /tmp/out.txt'; cat /tmp/out.txt
hi
$ mix -c 'echo l1 > /tmp/log'; mix -c 'echo l2 >> /tmp/log'; cat /tmp/log
l1
l2
$ mix -c 'ls /nope 2>&1' | head -1
ls: cannot access '/nope': No such file or directory
$ mix -c 'ls /nope &> /tmp/both.txt'; cat /tmp/both.txt
ls: cannot access '/nope': No such file or directory

Ordering matters exactly like a shell: >f 2>&1 sends both streams to f, while the unusual 2>&1 >f keeps stderr on the original stdout.

Mix dups only fds 1 and 2. An unsupported fd target (>&3, >&-, 3>&1) is a clear error, never a silent junk file:

$ mix -c 'echo hi >&3'
unsupported fd in redirect '1>&3' — Mix dups only fds 1 and 2

A bare & is a word-breaking metacharacter only immediately before > (cmd arg&>out redirects, no stray arg&); a lone & mid-word is literal, and a trailing & is background.

mix meta-commands under plumbing (interactive REPL)

A bare mix <sub> line at the REPL is a meta-command: it runs in-process so it can read this shell's live state (mix vars, mix history, mix trace on, live mix stats counters). In-process output prints straight to the terminal, so plumbing on the line — a pipe tail, any redirect, a background &, or an env prefix — cannot be honoured by that path.

Since 0.61.1 a plumbed mix … line is therefore not intercepted:

  • Stateless subcommands (stats, builtins, man, keywords, help, version, config, what, …) run as a normal external pipeline, so mix stats never | wc -l, mix stats never >> log, and mix builtins --json | jq . all work. Live stats are flushed to disk first, so the external mix stats child reads current data including this session.
  • Live-state subcommands (history, vars, aliases, functions, all, type, status, context, snapshot, ask, chat, trace, diagnose, reload, build, update, and bare mix) are refused loudly with exit status 2 — an external child would silently answer from different state. mix stats reset / mix stats clear are refused under plumbing for the same reason: they mutate the persisted store under an invariant that spans this shell's live counters.
alpha ~ mix history | wc -l
mix history: runs inside the interactive shell — pipes, redirects and & are not supported; run it bare

Before 0.61.1 the plumbing was silently dropped: mix stats never | wc -l printed the report and never ran wc; mix stats never > x created nothing.

All of the above applies to a single-pipeline line. A ;/&&/|| chain is a different intake with no meta-command interception (shell builtins like cd still run in-process there): mix vars; echo hi answers vars from a fresh external child (empty), with no refusal and no stats flush. Don't put live-state meta-commands — or the mutating mix stats reset / mix stats clear — in chains.

Variables and the injection invariant

On a shell-dispatch line, $NAME / ${NAME} resolve from the Mix scope, then the environment:

$ X=hello mix -c '/bin/echo "got: $X"'
got: hello

The structural decisions — where the operators, redirects, name= assignments, fd prefixes, and brace groups are — are fixed from the literal line text in a pass that never sees a value. So a variable's value can never inject syntax: it is always a single data word, never a ;, a &&, a redirect, or an extra argument.

$ X='a; echo PWNED' mix -c '/bin/echo "[$X]"'
[a; echo PWNED]

The ; inside $X is data, not a command separator. The same holds for $(...) results — captured stdout is spliced as data and never re-parsed for operators.

A leading name=value (literal key only) sets an env var for that command, bash-style:

$ mix -c 'FOO=bar /bin/sh -c "echo \$FOO"'
bar

In-process builtins under -c

A handful of builtins are handled in-process rather than spawned. cd is intercepted as a lone command and in a single-segment foreground &&/||/; chain, so it changes the directory instead of failing to spawn a nonexistent cd binary:

$ mix -c 'cd /tmp && pwd'; echo "rc=$?"      # cd in a chain: in-process
/tmp
rc=0
$ mix -c 'cd /nope && pwd'; echo "rc=$?"     # failing target: rc=1, not 127
cd: /nope: No such file or directory (os error 2)
rc=1

The narrow exceptions still spawn (and fail) so a redirect or env prefix is never silently dropped — a piped, backgrounded, redirected, or env-prefixed cd:

$ mix -c 'cd /tmp | cat'; echo "rc=$?"       # piped cd: spawns, fails
mix: cd: No such file or directory (os error 2)
rc=127

exit is intercepted only as a lone command; in a chain it spawns like any external program and fails. Note the shell-layer exit N parses N as a plain i32 (an unparseable N — including a fractional one — silently exits 0, and the OS truncates a status above 255), while the language builtin exit(n) validates 0..=255 and raises VALUE_OUT_OF_RANGE outside it (since 0.59.0) — a known language/shell-layer divergence, queued for its own fix. The bare mix head is self-resolved to the running binary (so mix --version re-enters this executable regardless of PATH), and a bare mix with no subcommand under -c is refused (it would start a nested REPL). The REPL-only builtins (jobs, fg, bg, pushd, popd, history, unalias) have no meaning under -c.

$ mix -c 'mix --version'
mix 0.21.2

The two-context split

The single most important rule. The same characters mean different things depending on whether they are on a shell-dispatch line or inside a Mix string:

ConstructOn a shell-dispatch lineInside a Mix "string"
$(cmd)runs cmd, splices stdout (one word)literal text $(cmd)
{a,b} / {1..5}brace-expandsliteral text (a {...} in Mix source is a map literal or literal text)
$((1+2))literal (arithmetic, unsupported)literal text
${NAME}env/scope interpolationscope→env interpolation
bare $nameenv/scope interpolationliteral text (the opposite of bash)
$ mix -c 'print("{1..3}")'        # Mix string: braces are literal
{1..3}
$ mix -c 'print("$(echo hi)")'    # Mix string: $(...) is literal
$(echo hi)
$ mix -c 'echo {1..3}'            # shell line: braces expand
1 2 3

The trap: you reach for $(...) or {a,b} inside a Mix double-quoted string and nothing happens — they are inert data there. To splice a command's output in Mix code, use run() / run_rc() and .. concat:

$count = trim(run("ls /etc | wc -l"))
print("etc has " .. $count .. " entries")

Two narrow exceptions where $(...) does substitute in Mix source: a heredoc body, and the standalone $(cmd) expression (see strings). And in a run() / ssh_run() command string, a $(...) passes through to the target shell that runs the command (local for run, remote for ssh_run). For multi-line remote Mix or nested quotes, skip that extra parse with the ssh_mix + heredoc idiom.

Timing — the time modifier

time is a modifier, not a command — exactly as in bash, where time is a shell keyword and not a program. Mix resolves it before dispatch, so it wraps whatever the rest of the line turns out to be: an external command, a pipeline, a chain, a bareword function, or Mix code.

$ time shwho example.org
...
Elapsed: 412.181ms

$ time sum([1, 2, 3, 4, 5])
15
Elapsed: 0.012ms

$ time ls | wc -l
18
Elapsed: 1.251ms

The elapsed line goes to stderr, so a timed command's stdout stays clean for a pipe or a redirect. The timed command's exit code is passed through unchanged. Sub-second runs report milliseconds; longer ones promote to 1.500s / 1m5.000s.

time is resolved before alias expansion, so the wrapped head still expands — time ll times ls -l. A bare time with nothing to run reports usage and exits 2.

Being resolved before dispatch, the modifier shadows every other time: a time binary on PATH (GNU /usr/bin/time — reach it by full path, as Mix has no command builtin), an alias time = …, and a user-defined Mix function time. Bash's keyword shadows a PATH time the same way. Rename any Mix function you want to keep callable bareword; a paren call is unaffected.

time() — with parens — is the unrelated time() builtin returning a Unix timestamp, not the modifier: only a bare time HEAD followed by something to run is stripped. timeout 5 cmd is likewise untouched (the head must be exactly time).

mix time EXPR is the same modifier under its older spelling, kept working.

The modifier lives in the shell-dispatch layer, so it is available at the prompt, under -c, and over ssh (ssh host 'time shwho example.org') — but NOT in a .mix script (parsed as Mix, not dispatched line-by-line) and NOT in a sourced line or ~/.mixrc. To time a region of a script, subtract two time() reads, or use the duration_ms field that run_argv() already returns.

Two lines report no elapsed. A backgrounded command (time sleep 5 &) returns at spawn, so there is no runtime to measure — it says backgrounded (&) — not timed rather than print the spawn latency as if it were the duration. And an exit() mid-line (or a mix build that re-execs) never returns, so nothing survives to report; bash does print a timing for time exit, Mix does not.

time;, time& and time| have nothing to run and report usage.

What shell-dispatch is NOT

Shell-dispatch handles a command, a pipeline, or a chain — not shell control flow. There is no for/while/if/case shell syntax, no shell functions, no $? (use the structured return of run_rc()). Those constructs are Mix:

$ mix -c 'for x in $(echo a b c); do echo "[$x]"; done'
mix: for: No such file or directory (os error 2)
mix: do: No such file or directory (os error 2)
mix: done: No such file or directory (os error 2)

Mix saw for as a keyword, tried to parse Mix, and do is not even a Mix keyword — so it fell back to the shell path and tried to run for / do / done as programs. Write it as a Mix loop instead:

for each $x in split(trim(run("echo a b c")), " ")
  print("[" .. $x .. "]")
end
[a]
[b]
[c]

When in doubt: a PATH/bare command head → shell dispatch; a $/Mix-keyword/call head → Mix. Operators later on the line—including ;—do not override the head-first classifier.

Aliases (-i only)

~/.mixrc aliases and PATH load for interactive logins (mix -i, mix -i -c, the REPL) — not a plain mix -c. So a non-interactive ssh host '<line>' runs with no aliases. Aliases are expanded on the first word before classification, so an alias expanding to a chain (u = "sudo apt update && sudo apt upgrade") lands on the shell path correctly.

Terminal output mode (interactive REPL)

The interactive REPL owns the terminal's output post-processing: before every prompt it re-asserts OPOST | ONLCR on stdout, so a bare \n from an external command always arrives as CR-NL and lines start at column 0. This repairs an inherited raw tty (Mix started as a login shell on a terminal left with ONLCR off) or a foreground child that exited without restoring it — the classic "staircase", where each line steps rightward, can't persist across a prompt.

Consequence: a stty -onlcr you run at the Mix prompt does not stick — Mix re-enables CR-NL output on the next prompt. Mix touches only those two output bits; input flags, control characters, flow control and every other stty setting are left alone. Non-interactive modes (mix -c, scripts, mix --serve) never touch the tty.

See also

mix help              the full categorized builtin reference
mix what run          one-line help for the run() builtin
mix keywords          every Mix keyword (so you know what the classifier reserves)
  • overview — the language at a glance
  • running-commandsrun / run_rc (via /bin/sh, structured returns), run_argv / run_stream (argv lists, no shell)
  • strings'raw' vs "${...}", heredocs, the literal-$(...) rule
  • keywords — Mix keywords the classifier routes to Mix
  • control-flowif / for each / while (the Mix replacements for shell control flow)
  • bussend / emit / on … end, the Bus-native side of the language
  • The Mix repoAGENTS.md is the agent orientation sheet on top of this manual