Invoking Mix

mix is one binary with several entry modes: an interactive shell (REPL), a script runner, a -c one-liner, a stdin reader, a syntax checker, a supervised Bus daemon, and a family of one-shot meta commands (mix help, mix man, mix stats, …). The same binary is also Mark's mesh login shell, so ssh host '<mix source>' is evaluated by Mix, not bash. This page covers how to start Mix in each mode and the rules that decide whether a -c / login-shell line runs as Mix code or dispatches a shell command.

The binary is the oracle; page stamps are held fresh by cargo test -p system --test man_pages. Argument parsing lives in mix-shell/src/lib.rs; the shell-first classifier is in mix-shell/src/shell.rs.

$ mix --version
mix 0.89.1 (f886d310df76, built 2026-09-21T03:14:00Z)

The line carries the short git sha of the build, suffixed -dirty when the tree was modified at compile time, and the build time — a semver alone cannot tell a stale binary from a fresh one. It is the shape every mixos binary answers with, except that mix names itself mix rather than its crate mix-shell. mix --version --json gives the same provenance machine-readably (component, version, git_sha, git_sha_full, git_dirty, build_time).

A version query does nothing else. It is answered before the base-env capture, the native-session lane, Bus dispatch and the evaluation thread, so it neither starts a session nor disturbs one: mix --version is truthful and side-effect-free while other mix processes are running (0.89.1). Only argv[1] is a version query for the interpreter — mix -c 'print("--version")' runs the program, as before. A --version straight after a script path asks for the script's version instead: see --version for scripts.

Modes at a glance

FormWhat it does
mixInteractive REPL (loads ~/.mixrc)
mix script.mix [args…]Run a file from the OS shell; args via args() / $1, $2, …
mix script.mix [args…] at the REPLRun the file in a clean child process; the REPL survives
mix -c '<code>'Run one line of Mix or dispatch a shell command (shell-first classifier)
mix -i -c '<code>'As -c, but load ~/.mixrc first (aliases + PATH), like bash -ci
mix - [args…]Read the whole script from stdin (clean byte channel)
mix --check <script>Parse without executing — a syntax check
mix lint [flags] FILE...Semantic analysis: undefined names, arity, must-use, stable machine-readable diagnostics (0.29.0 — see lint)
mix --no-prelude …Skip loading the standard prelude (applies to any run mode)
mix --no-traceback …Uncaught errors print the legacy single line instead of a traceback (0.29.0 — see errors)
mix --strict-arity …Strict call arity: wrong-arity user-function/builtin calls raise catchable ARITY_MISMATCH instead of the compatible missing→nil / extra-ignored binding (0.29.0 — see functions). The default for script/-c/serve modes since 0.103.0 — --compat-arity restores the compatible binding, and the REPL keeps it unless ~/.mixrc opts in (the REPL reads neither the flag nor MIX_STRICT_ARITY — use $strict_arity = true in ~/.mixrc there). Explicit opt-ins (now redundant but harmless): MIX_STRICT_ARITY=1, $strict_arity = true in ~/.mixrc, and ssh_mix(host, src, {strict_arity: true}); the remote escape hatch is ssh_mix(host, src, {strict_arity: false}) → --compat-arity - on the remote (0.103.2). A mix flag placed AFTER the script/-c source is a script argument, never a flag — mix warns when a trailing argument matches one of its own flags
mix --compat-arity …The strict-arity escape hatch (0.103.0): the compatible missing→nil / extra-ignored binding in script/-c/serve modes, with a one-per-(builtin,count) stderr warning on a surplus (0.102.8)
mix --no-lint …Skip the -c/stdin pre-execution lint gate (0.103.4)
mix --agent …Print the gate's soft diagnostics to stderr before running (same as MIX_LINT=warn; the hard set refuses regardless) (0.103.4)
mix --serve <script> [--name <svc>]Run the script as a supervised Bus daemon citizen — see serve
mix --version / -VPrint version + build hash, and nothing else — no session lane, no Bus (0.89.1)
mix script.mix --version / -VPrint the script's version line without running it (also mix --serve script.mix --version, mix - --version) — see below (0.95.0)
mix --help / -hUsage summary
mix stats [sub…]Window-labelled usage reports and static authorship coverage — see stats
mix help / mix what <name> / mix man [topic]Builtin/keyword reference and this manual — see the mix meta-command

mix help, mix what, mix man, mix status, mix build, … are the same meta commands the REPL's bareword mix dispatcher accepts, exposed as one-shot CLI commands: mix man invocation prints this page; bare mix man prints the manual index. In both contexts, meta names are checked before the script-file fallback, so a script named build, status, or test in the CWD is shadowed — prefix the path with ./, for example mix ./build. Full list on the cli page. An unknown option (mix --zz) errors and exits 1; an unknown REPL meta token names the token, notes that no such file exists, then shows the meta-command overview.

Bare mix with no arguments starts the REPL — including when stdin is a pipe. The piped lines are then read and executed one at a time through the shell-first classifier (with ~/.mixrc loaded), so a pipe into bare mix does run code — it works, but it is not the intended path. For a script use mix <file> or mix -: a whole-program parse over a clean byte channel, no per-line classification, no ~/.mixrc.

All modes that evaluate Mix record best-effort usage by default and are tagged as interactive, script, c, stdin, or serve. Set MIX_STATS=off (also 0 or false) to skip collection and all stats I/O. See usage statistics.

When Term supplies a native-session launch fd, Mix consumes and closes it and scrubs MIXOS_SESSION_FD before startup hooks or user source. Binding and renews run on a private resident thread; the seed has no Mix language or mix context representation. Invalid bootstrap input reports the failed stage and leaves the shell usable. Without the marker, all entry modes keep their ordinary silent startup path.

Running a script file

The first non-flag argument is the script path; everything after it is passed to the script.

-- greet.mix
$all = args()
print("argc=" .. length($all))
print("first=" .. $1)
for each $a in $all
  print("arg: " .. $a)
end
$ mix greet.mix alpha beta
argc=2
first=alpha
arg: alpha
arg: beta

Two ways to read arguments, both populated the same way:

  • args() returns the list of script arguments (not counting the script path, and not counting any flag given to mix itself — mix --strict-arity greet.mix alpha passes one argument, not two). See builtins.
  • Positional $1, $2, … are the same arguments by index; $0 is the script path / filename. These are the one place a bare sigil number is allowed to be unbound without raising — an out-of-range positional reads as nil.

include inside a script resolves relative to the running file's directory (not the CWD), because the runner records the script path (it is also $0, and error diagnostics attribute to it). The every-time, CWD-relative loader is source. See modules.

The script's exit code is 0 on success, 1 on an uncaught error (the error message goes to stderr). A Ctrl-C interrupt exits 130 (128 + SIGINT) in non-interactive execution and with --result-fd, even if a catch returns successfully; plain execution on an interactive terminal retains status 0.

A command line ending in an odd run of backslashes may continue onto the next physical line. Mix splices it before classification, so the same command behaves identically here, under -c, and in the REPL. Even trailing runs are literal; backslashes inside Mix strings and heredoc bodies are untouched. See syntax.

At the REPL or login-shell prompt, mix FILE [args…] launches the same script mode as the OS-shell form in a child process. It therefore gets a clean scope, $0 is FILE, and the remaining words populate args() and $1, $2, …; when it finishes, the existing REPL resumes. Use source FILE when the file should instead execute in and modify the current session scope. Meta names keep precedence over files: if status exists in the CWD, mix status still shows status and mix ./status runs the file.

A script that registers on <cmd> … end handlers does not exit after its last statement — it stays alive pumping events for those handlers. For the supervised daemon variant (broker registration, reconnect, journald logging), use --serve.

--version for scripts

Every mixos binary AND every Mix script answers --version with build details (Mark's rule, 2026-09-25). A script declares its version with one header line in its leading comment region:

#!/usr/bin/env mix
-- version: 1.4.0
-- deploy_widget.mix — ship the widget to the fleet

and mix answers for it:

$ mix deploy_widget.mix --version
deploy_widget.mix 1.4.0 (3f9a0c1d22be, modified 2026-09-25T01:14:07Z; mix 0.95.0 (a1b2c3d4e5f6))
$ mix plain.mix --version
plain.mix unversioned (9e107d9d372b, modified 2026-09-24T22:03:41Z; mix 0.95.0 (a1b2c3d4e5f6))
$ mix deploy_widget.mix --version --json
{"mix":{"dirty":false,"sha":"a1b2c3d4e5f6","version":"0.95.0"},"modified":"2026-09-25T01:14:07Z","name":"deploy_widget.mix","sha":"3f9a0c1d22be","sha256":"3f9a0c1d22be…","version":"1.4.0"}

The contract:

  • One line, exit 0, script not run. The fields are the script's basename, its declared version (or unversioned), the first 12 hex digits of the SHA-256 of its bytes, its mtime as RFC 3339 UTC, and the interpreter's own mix --version provenance (-dirty suffix included). The hash is what proves two nodes run the same bytes; the header is what a human bumps.
  • --json straight after the flag prints the same facts as one JSON object, the same map script_version() returns (version and modified are null when absent), matching mix --version --json. Its keys come out in a different order from the in-script map; JSON objects are unordered, so read them by name.
  • Read, never parsed. The answer comes from the same cold position as mix --version — no session lane, no Bus, no prelude, no rc — and the file is only read, so a script with a syntax error still reports its version. An unreadable path prints the usual Error reading '…' and exits 1. The bytes and the mtime come from one open of the file, so they always describe the same inode.
  • Symlinks. The name is the basename of the path as given (the link's own name, so ~/.local/bin/zcode reports zcode); the hash and the mtime are the target's. The same rule holds for a running --serve citizen's script_version(), although serve reads the script by its canonical path.
  • The header form is exactly -- version: X.Y.Z (optional -pre / +build suffix), and it must sit in the leading comment region: an optional #! shebang on line 1, then only blank lines and -- comment lines, within the first 32 lines. The scan stops at the first line that is anything else, so a -- version: inside a heredoc or string further down — a script that generates scripts — is never taken for this file's header. Whitespace-tolerant (--version:1.4.0 counts), but lowercase and the colon is required: -- version 1.4.0 is prose, not a header. The first -- version: line wins; one whose value is not X.Y.Z makes the script unversioned, and mix lint names that line (MIX-D3016).
  • Mix owns exactly one position: the first argument after the script path. mix script.mix --version and -V there are Mix's; anywhere else they are ordinary script arguments — mix script.mix x --version runs the script with args() = ["x", "--version"]. The interpreter flags that may come before the script (-i, --no-prelude, --no-traceback, --strict-arity) do not change this. Under --serve the position is the one right after the script path, so mix --serve s.mix --name x --version and mix --serve s.mix --no-prelude --version are NOT queries (serve refuses the stray --version as a usage error).
  • Opting out: -- version-flag: script. A script that must answer --version itself — a wrapper that forwards argv to another program, such as ctl/_bin/zcode.mix behind ~/.local/bin/zcode — declares this line in the same leading comment region. mix then does not take position 1: the script runs with --version in args() and is expected to answer the flag itself. Only the exact value script counts. --serve still answers for an opted-out script, because a daemon has no argv to hand the flag to, so mix --serve SCRIPT --version is the cold provenance route for an opted-out script: it only reads the file, whether or not the script is a citizen, and never starts it.
  • mix --serve script.mix --version answers the same way and exits before any broker connect. mix - --version reads the script from stdin and names it -, with no modified field: - 1.4.0 (3f9a0c1d22be; mix 0.95.0 (a1b2c3d4e5f6)).

A running script reads the same facts as data with script_version(): {name, version, sha, sha256, modified, mix: {version, sha, dirty}}, where version is nil for an undeclared header and modified is nil for stdin.

  • It describes the entry script: a required module that calls it gets the entry script's record, not its own.
  • It is nil in the REPL, under -c, and in any embedder that runs Mix without installing a record (the MCP mix_execute tool, a Rust host calling the evaluator directly).
  • It is held per evaluator, so under --serve each RELOAD generation answers for its own file: a handler of the old generation still draining after a reload reads the old record, the new script's init and handlers read the new one, and a reverted reload leaves the old one untouched.
  • Its mix block is deliberately not mix_version()'s shape. It carries the version string and build sha of the interpreter, which is what provenance needs, where mix_version() splits the number into major/minor/patch for version gates.
$v = script_version()
log("starting " .. $v.name .. " " .. ($v.version ?? "unversioned") .. " @ " .. $v.sha)

mix -c — one-liners and the shell-first classifier

mix -c '<code>' runs a single line. It is shell-first: the same classifier the REPL uses decides whether the line is Mix code or an external command. This is what makes Mix usable as a login shell — mix -c 'hostname' dispatches a command, mix -c 'print(1 + 1)' evaluates Mix.

$ mix -c 'print(1 + 1)'
2
$ mix -c 'echo hello from shell'
hello from shell

How the classifier decides (shell.rs classify_input)

A line starting with # or -- is a comment (empty input). Otherwise the first word (after skipping KEY=VALUE env prefixes and expanding any alias) routes the line:

  1. Starts with $ → Mix code (e.g. $x = 1), with a shell-chain fallback.
  2. A Mix statement-keyword head (print, if, for, send, function, try, …) → Mix, also with the shell-chain fallback. true / false / nil are deliberately not keyword heads (since 0.21.0): a bare true or false line routes to /usr/bin/{true,false} so false exits 1 like every shell, and nil (no external binary) falls through to the Mix parse. Inside Mix source the lexer still owns all three literals ($x = true).
  3. A shell builtin head (cd, pushd, popd, history, exit, which, type, jobs, mix, …) → external path.
  4. A tight-hyphenated command-shaped head (mixos-comp, systemd-nspawn) → external command before Mix can parse it as subtraction, whether or not it is on PATH. The rule is statement-head-only and requires an ASCII letter/_ start with only ASCII letters/digits/_/-; spaced a - b, sigil/numeric subtraction, and expression-position print(a-b) stay Mix. Scope does not affect the decision: bare a is the string "a", not $a, so a-b remains a command when $a is live.
  5. First word found on PATH or is a path (./x, /bin/ls) → external command (design principle: shell-first). Routing is head-first — && / || / ; never force the shell path by themselves: a Mix-keyword head with a chain stays Mix (Mix has native statement chaining), an external head parses the chain on the shell path.
  6. Otherwise → parse as Mix (function call, expression). If that fails definitively, a genuinely command-like head (gti status) reports the familiar "command not found" and exits 127; a Mix-shaped head — a lexer failure (0755), an operator, a glued call/index opener (print(…) — surfaces the real lex/parse error and exits 1. An unknown command-like semicolon list (zqxfoo; zqxbar) stays on the shell path even though each bare word could parse as a discarded Mix string; this preserves the pre-0.31 shell result instead of silently succeeding. A known preserved wart is that a non-keyword alphabetic head such as not $x; print(1) is also shell-routed and exits 127; use a Mix keyword, sigil, or parenthesized call to make the intent explicit.

Two consequences worth internalising:

$ mix -c 'false' ; echo $?    # routes to /usr/bin/false — exits 1, like every shell
1
$ mix -c 'true' ; echo $?
0
$ mix -c 'print(false)'       # the boolean literal, inside Mix source
false

A dispatched command's exit status becomes Mix's exit code (mix -c '/bin/sh -c "exit 7"' exits 7). && / || / ; chaining and pipes work on the external path, and a plain foreground cd works inside chains (cd /tmp && pwd) — -c also has bare cd (→ $HOME), cd -, and ~-expansion. See the shell page for the full dispatch grammar (redirects, brace expansion, $(…) substitution).

A definitive Mix lex/parse error exits non-zero with the real diagnostic — it is not silently retried as a command:

$ mix -c 'print(0755)'
Lexer error at line 1:7: ambiguous leading-zero number '0755' — use a 0o (octal) / 0x (hex) / 0b (binary) prefix, or drop the leading zero(s) for decimal
$ echo $?
1

The bare-name footgun

Every Mix variable needs a $ sigil. A bare name = value line is read as a shell command, not an assignment:

$ mix -c 'x = 1'
mix: x: No such file or directory (os error 2)
$ echo $?
127
$ mix -c '$x = 1
print($x)'          # the $ sigil makes it Mix
1

See variables for the sigil rules.

Multi-statement -c

Mix statements may be separated by ; or real newlines. Semicolons are the compact one-line form:

$ mix -c '$x = 5; $y = 7; print($x * $y)'
35

Literal newlines remain useful for a readable multi-statement body:

$ mix -c '$x = 5
$y = 7
print($x * $y)'
35

Positional args under -c

Everything after the code string is a positional $1, $2, … — even a word starting with - (so flags like --no-prelude must come before -c). $0 is unset (nil) under -c.

$ mix -c 'print("got " .. $1 .. " and " .. $2)' one two
got one and two

A bare mix inside a -c line is refused (it would otherwise launch a nested REPL):

$ mix -c 'mix'
mix: -c: bare 'mix' would start an interactive REPL — ignored

mix -i — load ~/.mixrc first

-i makes a -c run interactive-flavoured (≈ bash -ci): it sources ~/.mixrc first, so your aliases and the toolkit's PATH are in scope before the line is classified and run. The combined spellings -ci and -ic mean the same thing.

$ mix -i -c '<line>'     # aliases expanded, ~/.mixrc PATH applied
$ mix -ci '<line>'       # same

Without -i, a plain mix -c has no aliases and does not read ~/.mixrc (≈ bash -c). ~/.mixrc also auto-loads for the plain interactive REPL (mix with no args). It does not load for a non-interactive ssh host '<mix>' — that path is closest to mix -c.

mix - — read a script from stdin

mix - reads the entire program from stdin as a clean byte channel — no shell re-quoting, nothing written to disk. This is the residue-free way to ship a script to a remote host (it is also the transport under the ssh_mix builtin — see remote).

$ echo 'print("from stdin: " .. (2 * 21))' | mix -
from stdin: 42
$ echo 'print("name=" .. $1)' | mix - worldarg
name=worldarg

Args after - are the script's positionals, exactly as with a file. $0 is set to "-". Unlike a pipe into bare mix (which the REPL consumes line by line through the classifier), mix - parses the whole program up front.

Running as a login shell and over SSH

When /opt/mixos/bin/mix is root's login shell on a host, ssh host '<cmd>' is evaluated by Mix, not bash. The universal shell -c contract still holds because the login path runs through the same shell-first classifier:

  • ssh host hostname — bare command, dispatched natively (no Mix needed).
  • ssh host 'mix status' — mix self-resolves via current_exe, no PATH needed for this case.
  • ssh host '<mix source>' — send Mix source to get Mix behaviour, e.g. ssh host 'print(run("hostname"))'.
$ ssh node1 'print("hostname is: " .. run("hostname"))'   # node1 runs mix as its login shell
hostname is: node1

(Locally, echo '<mix source>' | mix - runs the same script against this host.)

From a Mix script, drive a remote mix through the ssh builtins instead of raw ssh: ssh_run($host, $cmd) for a one-liner, and ssh_mix($host, $source) — which ships whole Mix source over ssh stdin into /opt/mixos/bin/mix -, bypassing every shell-quoting layer — for anything with quoting. A remote whose mix lives elsewhere takes the mix_bin option, or set MIX_REMOTE_BIN locally for every call (see ssh_mix). The ssh_mix + heredoc headline idiom shows the interpolation and bindings rules.

Two PATH gotchas to remember on the remote side:

  • Inside run / run_rc (which spawn /bin/sh with a minimal PATH), call binaries by full path — /opt/mixos/bin/mix, not bare mix. See running commands.
  • A non-interactive ssh host '<mix>' has no aliases (it's the mix -c path, not mix -i).

For Mix-as-a-shell language facts (&&/||/; chaining, pipes, redirects, brace expansion, $(…) command substitution on a shell-dispatch line), see the shell page.

--no-prelude

Mix loads a small standard prelude (Mix-source helpers) before running your code; --no-prelude skips it. It applies to any run mode (-c, file, -, --serve). Builtins are compiled in and are unaffected — only prelude-defined helpers disappear. Give the flag before the code string / script path (after them it would be read as a script argument); --serve also accepts it after the script path.

$ mix --no-prelude -c 'print("" .. range(1, 3))'   # range is a builtin, still works
[1, 2, 3]

The prelude source is mixos-lib-mix/std/prelude.mix, compiled into the binary. A user override at <base>/prelude.mix is honoured if it exists, where <base> is $MIXOS_SRC when that variable is set, otherwise $MIXOS. (Setting $MIXOS_SRC replaces the base — $MIXOS/prelude.mix is then not consulted.) Otherwise the built-in prelude is used.

--check — syntax check without running

--check lexes and parses a script and prints <file>: OK, or the first parse/lex error and exits 1. Nothing is executed.

$ mix --check good.mix
good.mix: OK
$ echo $?
0
$ mix --check bad.mix
Parse error at line 3:1: expected End, got Eof
$ echo $?
1

Use it in CI or a pre-commit hook to catch unterminated blocks (missing end), unclosed strings, and lexer errors before deploy.

--serve — run a script as a supervised Bus daemon

mix --serve <script> [--name <svc>] runs the script as a long-lived, supervised Bus citizen instead of a one-shot script. It connects to the local broker (noded), registers, and runs an unconditional event pump that dispatches your on <cmd> … end handlers until SIGTERM/Ctrl-C or a QUIT.

$ mix --serve worker.mix              # Bus service name = script stem ("worker")
$ mix --serve worker.mix --name probe # explicit service name

Name derivation (SPEC 18 §3.1): --name wins; otherwise the default is the script's file stem. A leading mixos- is stripped from either source (/usr/local/lib/mixos/statecache.mix → statecache, and --name mixos-foo canonicalises to foo). An anonymous serve with no derivable name is a launch error — never a nameless citizen. Serve mode takes no positional script args (a daemon has no argv); only --name and --no-prelude may follow the script path, in any order.

It differs from a plain run in four ways: a reconnecting supervised transport (a broker bounce is a transient drop, not a death); an unconditional event pump (a plain run pumps only when the script registered handlers); an exhausted initial connect budget that fails fast (exit non-zero) under systemd; and a runtime-reserved verb surface — HELP / INFO / QUIT plus <svc>.props.{get,list,describe} are answered by the runtime pre-dispatch, so an on handler can never shadow them. Logging goes to journald (stderr fallback off-systemd), tagged with service = <svc>. The full contract is on the serve page; see Bus messaging for on … end handlers and the broker model.

Resource note

Scripts run by the mix binary on the main thread get a raised recursion cap (128 vs the library default of 16). A runaway still returns a clean error rather than a native stack overflow. The binary does not sandbox its own operator — time/collection caps stay unset for binary-run scripts.

See also

This page as Markdown · This page describes the current main branch of MixOS.