The scua binary runs a script, scaffolds a new project,
formats source, runs tests, and steps a program under a debugger. Run it
with no arguments (or --help) to see the authoritative
usage:
$ scua
usage: scua [options] <file.scua> [args...]
(options precede the file; everything after the file — or after `--` — is passed to the
script and read with `sys.args()`)
scua init [name] (scaffold a new project in ./name, or the current dir)
scua init compact [name] (bare-bones: just a runnable main.scua)
scua init zed-debug|vscode-debug (add debug config to the current project)
scua fmt [--check] <file.scua> (fixes indentation + trailing whitespace only)
scua test [dir|file] (run *_test.scua files, or just the one named)
scua schema <file.scua> --type T (emit JSON Schema for a record/enum type)
scua pack [--tier=…] [--repo=DIR] (teach-a-model in-context pack)
scua cluster dump <file.scdb> (inspect a durable shared-config snapshot)
scua debug <file.scua> (run under the terminal stepper)
scua add|publish|vendor|… (package verbs; needs scua-pkg on PATH — `scua-pkg --help`)
scua --version | --help
options:
--fast fast-forward the clock (instant; for demos/sims)
--dump-bytecode[=verbose] print the compiled bytecode (annotated) and exit; don't run
--log=<trace|debug|info|warn|severe>
drop log statements below this level at compile time
--log-time prefix log lines with an ISO-8601 timestamp
--log-stdout send log lines to stdout, mixed in with the script's own
output (they go to stderr by default, as diagnostics)
--output=<text|json> text (default): stdout is what the script prints. json: stdout
is ONLY the `sys.emit` result; print/log go to stderr
--jit[=on|always|off] enable the JIT (ARM64 + x86-64; ignored where unavailable)
--jit-stats print JIT telemetry (compiles/entries/deopts) to stderr on exit
-D name[=value] set a `build` flag for `comptime if` (bare = true)
--profile <name> select a build profile from scua.toml (else its `default`)
--mod-path DIR add a module search directory (repeatable; tried after the entry dir)
--allow-file=PATH read capability grants from a TOML file (a [capabilities] table, or a
bare table): fs/net/env/serve/cluster/udp/tty, each `true` for the
bare form, a string/list for the constrained one, or `false`/`[]` to
refuse it outright. A --allow-* flag below always outranks it.
--allow-all grant EVERY capability, each in its broadest (bare-flag) form —
fs at the cwd, any host, any bind, any peer, every env var, the
terminal. For a script you already trust; a narrower --allow-* flag
still wins.
--allow-fs[=DIR] grant `fs` (read/write/delete) rooted at DIR (default: cwd)
--allow-net[=hosts] grant the `net` capability (HTTP); comma-separated host allowlist
--allow-env[=NAMES] grant the `env` capability; comma-separated name allowlist (bare = all)
--env-file=PATH load a `.env` file and grant `env` for exactly the names in it
(repeatable, applied in order). A real environment variable wins
over a file one, and an earlier file over a later one. Without
--allow-env the process environment stays hidden.
--allow-serve[=ADDR:PORT] grant the `serve` capability (HTTP/TCP LISTEN) constrained to that bind;
bare --allow-serve = any bind (trusted hosts only). Never implied by --allow-net.
--allow-cluster[=SECRET] grant the `cluster` capability (the `shared` config table); SECRET is
the cluster's shared MAC secret. Bare = empty (demo/trusted).
--allow-udp[=PEERS] grant the `udp` capability (DATAGRAMS); comma-separated allowlist of
ADDR[:PORT] or CIDR[:PORT] (e.g. 1.1.1.1:53, 10.0.0.0/8:7946).
bare --allow-udp = any peer (trusted hosts only). `udp.bind` also needs
--allow-serve. Never implied by --allow-net: datagrams can amplify.
--udp-max-payload=N raise the datagram payload cap from the 1200-byte portable-safe default
--allow-tty grant the `tty` capability (prompt the person at the keyboard):
sys.readline and sys.readpassword. Separate from piped stdin, which
sys.stdin() reads with no grant at all.
--io=async run fs/http capability I/O on an offload pool; default: blocking
--serve-debug put located fault text in http.serve 500 bodies (local dev only)
`--http-timeout=MS` — the whole-exchange deadline for `http` requests, in milliseconds (default `120000`; `0` = no deadline). A per-call `timeout_ms` may tighten this but never loosen it, so an operator can bound a script without editing it.
--locale=<tag|host> set sys.locale() (BCP-47, e.g. de-DE; `host` reads the device)
--tz=<±HH:MM|host> set sys.timezone() offset (e.g. +02:00, +05:45; `host` reads the device)
--max-ops=N cap reduction steps per turn (suffix k/m/g); kills on exhaustion
(without it there is no CPU limit — the built-in ceiling refills)
--max-mem=BYTES cap the script's extra live memory (suffix k/m/g, e.g. 64M)
--mem-stats print partition memory telemetry to stderr on exit (diagnostic)
--gc-threshold=BYTES set the GC pacing floor (default 8M; suffix k/m/g) — diagnostic
scua init, scua fmt,
scua test, and scua debug are subcommands
(they take the place of <file.scua>);
--version (or -V) prints the version and
exits. Each subcommand and option is detailed below.
#Running a file
$ scua file.scua
This compiles and runs the script. With no flags, timers run in real time: a script that waits five seconds takes five seconds. The options below change that and a few other things.
If the file can't be read, scua exits with status 2 (a
bad argument — see Exit status below) and an
error like:
scua: missing.scua: cannot read file
#Passing arguments to the script
$ scua [scua-options] <file.scua> [program-arguments...]
scua's own options come before the
file. Everything after the file name is passed to the program, verbatim
— including tokens that look like flags — and read with
sys.args():
$ scua --fast game.scua level3 --hard # sys.args() == ["level3", "--hard"]
Use a bare -- before the file to stop
scua's option parsing early (e.g. for a file or argument
that begins with -). A program argument that isn't valid
UTF-8 fails the launch (a SCUA string must be UTF-8). See Read command-line
arguments.
#Exit status
A program driving scua can tell what happened from the
status alone, without reading stderr.
| Status | Meaning | Who chose it |
|---|---|---|
0 |
Success | your script (or a clean finish) |
1 |
A clean negative result — "the thing you asked about isn't there" | your script, via sys.fail(reason) or
sys.exit(1) |
2 |
Bad arguments | scua, or your script via
sys.fail(reason, 2) |
3 |
The script didn't compile — a syntax, type or checker error. Nothing ran | scua |
4 |
The script asked for a capability this run wasn't granted. Nothing ran | scua |
5 |
The script crashed — an uncaught fault, in the main body or an actor | scua |
6 |
A kill-switch stopped the run — --max-ops or
--max-mem |
scua |
7 |
The runtime itself could not continue — you should not see this one | scua |
The split that matters for a tool: 1 means the script
ran and reached a negative answer, 3–7 mean it
never produced an answer at all. 4 in particular is worth
handling separately — the script is fine and the launch is what
has to change, so the fix is a --allow-… grant rather than
an edit.
0, 1 and 2 are
yours. They're the statuses sys.exit and
sys.fail are meant to choose between (see Write tools for AI agents).
scua never uses them for its own failures, and reserves
3–7 for them; keep your own choices in
0–2 and the two can't be confused.
sys.exit accepts any value 0–255,
so nothing stops a script picking 5 — it just makes its own
result indistinguishable from a crash.
scua test and scua fmt --check are
separate: both exit 1 when they find something (a failing
test, an unformatted file).
#scua init [name]
Scaffold a new project. With a name,
scua init creates that directory and fills it; with no
name, it scaffolds into the current directory:
$ scua init myproj
Created a new SCUA project in ./myproj:
myproj/main.scua
myproj/greet.scua
myproj/main_test.scua
myproj/scua.toml
myproj/README.md
myproj/.gitignore
Next steps:
cd myproj
scua main.scua # -> hello, world
scua test
The generated main.scua reads command-line arguments
with sys.args() and prints a greeting, so the project runs
straight away:
$ cd myproj
$ scua main.scua # -> hello, world
$ scua main.scua Ada # -> hello, Ada
$ scua test # 2 passed, 0 failed (1 file)
The greeting itself lives in a small greet.scua module, which is what
main_test.scua imports and tests — main.scua
just wires arguments to it. scua.toml is a starter project
manifest with debug and release build profiles
(see the --profile option below).
scua init never overwrites anything: if the target
directory already exists, or any file it would create is already
present, it stops with an error and writes nothing.
scua init compact [name]
The bare-bones cut — a single runnable main.scua and
nothing else (no module split, test, manifest, or README). Use it when
you just want one file to start from:
$ scua init compact myproj
Created a bare-bones SCUA project in ./myproj:
myproj/main.scua
Next steps:
cd myproj
scua main.scua # -> hello, world
The generated main.scua is a one-line program that
prints hello, world. Like scua init, it
accepts an optional name (creates that directory) or
scaffolds into the current directory, and never overwrites an existing
file.
scua init zed-debug
/ scua init vscode-debug
Add a debugger configuration to an existing project
so you can step through a .scua file in your editor —
zed-debug writes .zed/debug.json,
vscode-debug writes .vscode/launch.json. Both
target the scua debug adapter the SCUA editor extensions
register, which needs the scua-dap binary on your
PATH.
$ scua init zed-debug
Wrote .zed/debug.json — SCUA debug configurations for Zed.
Debugging needs `scua-dap` on your PATH — build it with `zig build dap`.
Each writes two launch configurations: one that always debugs
main.scua, and one that debugs the currently open file.
They operate on the current directory by default, or a project directory
passed as an argument (which must already exist).
Unlike the scaffolding commands, these update a config file that's already there rather than refusing: the SCUA configurations are inserted into your existing list, leaving your other debug configurations — and any comments or formatting — untouched. Running the command again when a SCUA configuration is already present does nothing.
scua fmt [--check] <file>
Reformat a file in place. The formatter is deliberately conservative: it fixes each line's leading indentation (two spaces per block level) and trims trailing whitespace. It does not reorder tokens, change spacing inside a line, or touch the contents of strings and comments. A reformatted file always means exactly what it did before.
Given this file with a missing indent and some trailing spaces:
fn greet(who)
return `hi {who}`
end
print(greet("Sam"))
Running scua fmt rewrites it to:
fn greet(who)
return `hi {who}`
end
print(greet("Sam"))
Add --check to verify without writing. It changes
nothing on disk and exits 0 if the file is already formatted, or
non-zero with a message if it isn't, which is what you want in CI:
$ scua fmt --check greet.scua
scua: greet.scua: not formatted (run `scua fmt greet.scua`)
Once the file is formatted, the same command exits 0 and prints nothing.
What counts as a level. Every block opener
(fn, then, do,
match, try, and the statement-position
partition/migrate/on/ask)
adds one level until its end, and every open bracket
( [ { adds one until it closes.
Brackets count individually, so a table or list that opens on the same
line as the call it is passed to sits two levels in,
and its closer sits one level in, because the call's ( is
still open when the } closes:
let m = machine.new({
initial = "cold",
transitions = [
{ event = "warm", from = "cold", to = "warm" },
],
})
The one exception is a bracket that immediately wraps a block opener,
each(items, fn(x) … end): the fn
provides the level and the ( adds none, so the body sits
one level in and end) returns to the call's column. A name
that happens to be spelled like a keyword (on = 2 in a
table, end: int in a record, spec.then) is a
name and opens nothing.
If the formatter reaches the end of a file with a block or bracket
still open, it refuses with a message and writes nothing, and
--check reports that as a failure. That is deliberate: an
opener with no closer would have pushed every following line one level
in, and no test can catch a wrong indentation because indentation is not
meaningful to the language.
Because the formatter only touches indentation and trailing
whitespace, spacing inside a line is left exactly as you wrote it.
a+b stays a+b, and
let x = 1 keeps its inner spaces. So
scua fmt and --check are no-ops on a file
whose indentation is already correct, even if its operator spacing isn't
to your taste. Normalizing intra-line spacing is not something the
formatter does today.
scua schema <file> --type Name
Emit a JSON Schema draft 2020-12 document for a
record or enum declared in
<file>. Used by agent harnesses and MCP as
inputSchema / outputSchema. Only the decidable
subset of where refinements maps; SCUA contracts stay
authoritative. See Write
tools for AI agents.
$ scua schema examples/agent_tool.scua --type Args
scua pack [--tier=compact|full|both] [--repo=DIR] [--out=PATH|-]
Generate a teach-a-model markdown pack (grammar, I/O
model, stdlib cheatsheet, few-shots from examples/,
reserved words, pitfalls). Token estimates print on stderr. Default
--tier=both writes scua-pack-compact.md and
scua-pack-full.md. Prefer compact in a system
prompt. See Write tools
for AI agents.
$ scua pack --repo=. --tier=compact --out=-
scua cluster dump <file.scdb>
/ scua cluster keys <file.scdb>
Inspects a durable shared-config snapshot — the file
a table written with cluster.open(name, { durable = "…" })
leaves on disk. It reads the file offline; it does not
join the cluster, so it's safe to run against a snapshot on any machine
without perturbing a live mesh.
dump prints every key with its value, in sorted order,
with deleted keys marked and the writing node and clock shown:
$ scua cluster dump game_config.scdb
economy/gems = 5 [node 1 @ 1784071308990]
economy/gold = 100 [node 1 @ 1784071308990]
flags/new_shop = true [node 1 @ 1784071308990]
graphics/res = (deleted) [node 1 @ 1784071308990]
# 3 live, 1 tombstone(s)
keys prints just the live key names, one per line —
handy for piping into another tool. A missing or corrupt file is a clear
error and a non-zero exit, not a crash.
#scua test [dir|file]
Find and run tests. It discovers every *_test.scua file
under dir (the current directory if you don't give one),
runs each, and reports pass/fail. It exits non-zero if anything failed,
so it drops straight into CI.
Name a file instead and it runs just that one — what you want constantly while working on a single test:
$ scua test tests/parser_test.scua
PASS tests/parser_test.scua test_parses_a_call
1 passed, 0 failed (1 file)
The *_test.scua suffix is how files are
discovered in a directory; a file you name yourself runs
whatever it is called. Discovery follows symlinks, so a test directory
assembled out of links works the same as one of real files.
A test file can work two ways. If it declares
fn test_*() functions, each one runs on its own: a fault in
one (a failed assert, say) fails just that test, and the
others still run. If it has no test_* functions, the whole
file is a single test that passes if it runs without an uncaught
fault.
-- math_test.scua
fn test_addition()
assert_eq(1 + 1, 2)
end
fn test_division()
assert_eq(7 // 2, 3)
end
$ scua test
PASS ./math_test.scua test_addition
PASS ./math_test.scua test_division
2 passed, 0 failed (1 file)
Write assertions with assert(cond, msg?) or the
comparison helpers assert_eq(got, want),
assert_ne(a, b), and
assert_near(got, want, eps) (float-tolerant). A failing
comparison reports both values. See Test your code for the full
walkthrough.
#debug
$ scua debug file.scua
Runs the script under a terminal stepper instead of straight through, so you can step line by line and inspect state as it executes. This is an interactive session in your terminal, not a flag you combine with normal output.
#Package verbs
$ scua add @acme/json
$ scua publish --registry /srv/acme-registry
$ scua vendor
These are handled by scua-pkg, a
separate binary on its own release stream, which scua
locates on PATH and executes — the same model as
git's and cargo's external subcommands. The
dispatched verbs are:
add remove update
upgrade fetch vendor
bundle verify why
explain diff sbom
trust doctor publish
yank search audit
registry
scua-pkg --help is the authoritative list and the place
their flags are documented.
Two things this arrangement guarantees, both deliberate:
- Running programs never needs it.
scua file.scua,scua test,scua fmt, andscua debugall work withscua-pkgabsent. Nothing on the script path touches the package manager. - No network code is in this binary. All fetching,
TLS, registry, transparency-log, and trust-policy code lives in
scua-pkg, which is what makes a network-free build flavour ofscuapossible at all.
If the tool is not installed:
$ scua add @acme/json
scua: E-PKG-TOOL-MISSING: `scua add` needs scua-pkg, and it is not on PATH.
…
The toolchain passes its own version to scua-pkg on
every dispatch (--toolchain 0.13.0). scua-pkg
supports the current and previous two toolchain minors, and refuses
outside that window with E-PKG-TOOL-SKEW rather than
emitting a lock or vendored tree this toolchain might half-read.
#--fast
Fast-forward the virtual clock. Timer-based waits resolve instantly instead of taking real time, while producing the same output and the same reported timestamps. Use it for demos, simulations, and tests where you don't want to sit through the waits.
A scheduling script that describes five seconds of game time runs in milliseconds:
$ scua --fast scheduling.scua
world: spawned 2 tasks
[patrol] move to A (t+0ms)
[regen] +10 hp (t+1000ms)
[regen] +10 hp (t+2000ms)
[patrol] move to B (t+3000ms)
[patrol] back to A (t+5000ms)
The t+...ms values are the in-program clock, so they
still read 1000, 2000, and so on even though no real time passed.
--dump-bytecode[=verbose]
Compile the script, print the bytecode the compiler produced, and
exit without running it. It's a diagnostic window into how your code
lowers, handy when you're curious what a construct compiles to or filing
a bug. --dump-bytecode=verbose prints more detail per
instruction.
$ scua --dump-bytecode hello.scua
The format is meant for a human reading it, not as a stable interface, so it can change between releases.
It is also the way to check that a whole tree still compiles, which is worth knowing when you upgrade. Because it stops before running, you can point it at every file you have and let the compiler answer, rather than searching for the constructs you think changed:
$ for f in $(find . -name '*.scua'); do scua --allow-all --dump-bytecode "$f" >/dev/null || echo "$f"; done
Two things make that better than a search. The compiler knows the exact set of escapes, keywords and forms it accepts and you are working from memory, so a grep encodes what you believe the language does. And the first error in a file hides the rest, so a file that failed for one reason may still hold another: after fixing one, compile again rather than assuming the file is clean.
--allow-all is there because resolving an
import of a capability module checks the grant, so without
it you get refusals for files that are fine. Files that reference a
host-provided global, or a declarations file, will still be reported,
and are the expected exceptions rather than problems.
#--log=<level>
Set the minimum log level. SCUA's logging has five levels, lowest to
highest: trace, debug, info,
warn, severe. Anything below the level you
pass is removed at compile time, so those statements and their arguments
never run. The default prints everything.
Given a script that logs at several levels, the default shows them all:
$ scua logging.scua
[info] player Mara joined
[debug] hp is 30/100
[warn] hp low: 30
[severe] save upload failed, retrying from cache
...
With --log=info, the trace and
debug lines are gone (and any work done only to build them
is skipped):
$ scua --log=info logging.scua
[info] player Mara joined
[warn] hp low: 30
[severe] save upload failed, retrying from cache
...
An unknown level is rejected:
$ scua --log=bogus file.scua
usage: --log=<trace|debug|info|warn|severe>
See Logging for the levels and how log statements are written.
#--log-time
Prefix each log line with an ISO-8601 timestamp. Useful when you want logs you can correlate with other systems.
$ scua --log-time logging.scua
2026-06-18T23:29:55.478Z [info] player Mara joined
2026-06-18T23:29:55.478Z [debug] hp is 30/100
...
#--log-stdout
Log lines go to standard error by default, because a
log line is a diagnostic rather than the program's output.
print and sys.write go to
standard output; info, warn and
severe do not. That means a caller can parse a script's
stdout without a log line turning up in the middle of it, and without
either side having to arrange anything:
$ scua tool.scua 2>/dev/null
{"ok":true,"total":"19.99 USD"}
$ scua tool.scua 1>/dev/null
[warn] two rows had no price
--log-stdout puts them back together, for when you want
a single merged stream to read yourself and don't care which is which.
(scua tool.scua 2>&1 also merges them, and keeps
them in the order the script wrote them — that ordering holds either
way.)
It combines with --log and --log-time; the
level floor and the timestamp work exactly as before.
#--output=<text|json>
Declare what this run's standard output is.
text is the default and the ordinary
case: stdout is whatever the script prints, and sys.emit's result
— if the script calls it — is appended to the same stream.
json says stdout is the tool's
result channel. sys.emit writes there and
nothing else does: print, sys.write and log
lines all go to standard error instead. So a caller gets exactly one
JSON value on stdout, whatever the script prints along the way:
$ scua --output=json tool.scua --a=1 --b=2
{"sum":3}
$ scua --output=json tool.scua --a=1 --b=2 2>&1 1>/dev/null
computing...
[warn] a diagnostic
Nothing is discarded — the script's own output is still there, on the other stream, and a script doesn't have to be written differently to be run this way. Use it whenever something is parsing the result; leave it off when a person is reading the output.
It's a flag on the run rather than on the script, because the caller parsing stdout as JSON is already the one who knows it wants JSON — so saying so costs it nothing, and a source file never has to describe how the process that runs it will be read.
#--jit[=on|always|off]
Turn on the optional JIT. By default SCUA runs on its interpreter;
--jit=on compiles the hot loops — the ones that run enough
times to be worth it — to native code, which makes long-running numeric
and table-heavy work much faster. The result is identical to the
interpreter: the JIT changes speed, not behaviour.
fn total(n)
let sum = 0
for i in 1:n do
sum = sum + i
end
return sum
end
print(total(1000000))
$ scua --jit=on sum.scua
499999500000
The three values:
--jit=oncompiles hot loops as they warm up. This is what you reach for on a compute-heavy script.--jit=offis the default: run everything on the interpreter.--jit=alwayscompiles eagerly instead of waiting for a loop to warm up. It's slower to start and mainly useful for testing that the JIT and the interpreter agree.
The JIT runs on ARM64 (Apple Silicon and ARM Linux) and x86-64 (macOS and Linux). On any other platform the flag is accepted and ignored, and the script runs on the interpreter, so a script and its output are the same everywhere, with or without the flag. See Make scripts run faster with the JIT.
#--jit-stats
Print a one-line summary of what the JIT did to stderr when the run
ends: how many functions it compiled, how many times native code was
entered, and how many times it fell back to the interpreter (a "deopt").
Use it to confirm the JIT is engaging on your hot path. Passing
--jit-stats also turns the JIT on, so you can use it on its
own.
$ scua --jit-stats game.scua
[jit] mode=on compiled=2 native-entries=5 deopts=3 ...
The exact counters are for diagnostics and vary from run to run and
release to release. A compiled= above zero with a healthy
native-entries= means your hot loop is running as native
code; all zeros means nothing ran long enough to compile.
#-D name[=value]
Set a build flag that comptime if reads from the
build config. comptime if chooses a branch at
compile time, and only the chosen branch is compiled into the program. A
bare -D name sets the flag to true;
-D name=value gives it a value. You can pass
-D more than once.
Given a script that gates output on build.debug and
build.tier, with no flags both gates take their default
branch:
$ scua conditional.scua
free edition
game starting
Setting the flags selects the other branches:
$ scua -D debug -D tier=pro conditional.scua
[info] debug diagnostics enabled
pro edition: all features unlocked
game starting
See Conditional
compilation for how comptime if and build
work.
#--profile <name>
Select a named build profile from a scua.toml project
manifest. A profile is a bundle of build flags, so you don't have to
type the same -D flags every time. The manifest names a
default profile that's used when you don't pass
--profile.
For example, a scua.toml like this:
default = "debug"
[build.debug]
debug = true
tier = "free"
[build.release]
debug = false
tier = "pro"
A plain run uses the default (here debug)
profile:
$ scua app.scua
[info] debug diagnostics on
free edition
Asking for the release profile applies its flags
instead:
$ scua --profile release app.scua
pro edition
The flags set by a profile are the same build flags
-D sets, so the same comptime if gates respond
to both.
#--mod-path DIR
Add a directory to search when resolving an import. By
default import name looks only next to the entry file
(<entry-dir>/name.scua); each
--mod-path DIR adds another directory, tried in
order, after the entry directory. Repeat the flag for several
directories. This is how you keep shared modules in a separate folder,
or split a project across directories.
$ scua --mod-path libs app.scua
$ scua --mod-path libs --mod-path ../shared app.scua
With app.scua containing import greet, the
runner tries ./greet.scua, then
libs/greet.scua, then ../shared/greet.scua,
and uses the first that exists. The built-in standard-library modules
(math, list, …) always resolve first and can't
be shadowed. If no directory has the module, the error names how many it
tried:
$ scua app.scua
scua: app.scua:1: cannot resolve module 'greet' (tried 1 resolver)
(The directories form an ordered resolver chain. Today the chain is set on the command line; a future release adds project-manifest and in-script control.)
#--allow-file=PATH
Grant capabilities from a file instead of a row of flags. This:
$ scua --allow-fs=./data --allow-net=api.internal,cdn.example --allow-env=PORT,HOME \
--allow-serve=127.0.0.1:8080 app.scua
becomes this:
# permissions.toml
[capabilities]
fs = "./data"
net = ["api.internal", "cdn.example"]
env = ["PORT", "HOME"]
serve = "127.0.0.1:8080"
$ scua --allow-file=permissions.toml app.scua
The keys are the flag names, so there is nothing new
to learn: true means the bare flag (fs = true
is --allow-fs, rooted at the working directory), a string
or list is the constrained form, and false grants nothing —
useful when you want a policy to say "not this" out loud rather than by
leaving it out.
Two rules worth knowing. An empty list grants
nothing: net = [] means no hosts, not any host.
And an unknown key is an error, not a warning — a typo
like nett would otherwise silently grant nothing and show
up much later as undefined name inside your script, with
the file still looking right.
The [capabilities] header is optional. A file that is
nothing but the table works too, so a dedicated permissions file needs
no ceremony.
false and [] are refusals, and they
stick. That is what lets you say "everything except one thing"
in a single command:
$ scua --allow-all --allow-file=no-net.toml app.scua # no-net.toml is just: net = false
Any key can be the one you refuse — net = false,
tty = false, fs = false — and the refusal
holds against --allow-all on the same command line.
Only a file you name applies. A scua.toml beside your
code grants nothing — capabilities come from what you type, so a script
cannot arrange to be trusted more next time.
#--allow-all
Grant every capability, each in its broadest form — fs
at the working directory, any host, any bind, any peer, every
environment variable, the empty cluster secret, the terminal.
$ scua --allow-all app.scua
This is for a script you already trust — usually your own. It is the honest answer to "just run it", which otherwise tempts people into not sandboxing at all; it is not something to reach for with code you have not read.
A narrower --allow-* flag still wins, which makes
"everything, except this one thing is restricted" a single readable
command:
$ scua --allow-all --allow-net=api.internal app.scua # everything, but net only reaches api.internal
#Which grant wins
Three places can grant a capability. They are resolved one capability at a time, most specific first:
- a
--allow-*flag on this command line --allow-file=PATH--allow-all
A flag is the most specific thing you can say, so it is never overwritten and never widened. Everything else fills in the capabilities you did not name.
All three are things you type. A capability file only applies when
you name it, so nothing a script can reach — including a
scua.toml beside your code — grants anything on its
own.
#--allow-fs[=DIR]
Grant the script the fs capability — the ability to
read, write, and delete files and directories — rooted
at a directory. Without this flag a script has no filesystem access at
all: import fs is a compile error, because the
fs module isn't installed (this is the default-deny
capability model). With it, fs is available and every path
the script uses is resolved within the granted directory;
absolute paths and .. are rejected, so the script can't
reach outside the root. That root is also the whole blast radius of
fs.remove/fs.remove_all, which cannot delete
the root itself and unlink a symlink rather than following it — so
grant the narrowest directory that works.
--allow-fs alone roots at the current working directory;
--allow-fs=DIR roots at DIR.
import fs
match fs.read("notes.txt")
Ok(text) -> print(text)
Error(why) -> print(why)
end
$ scua notes.scua
notes.scua:1: module `fs` needs the `fs` capability, which this run was not granted — grant it with `--allow-fs` on the CLI, or the host's capability API when embedding
$ scua --allow-fs notes.scua
(the contents of notes.txt)
See Read and write
files for the full surface
(fs.read/fs.read_text/fs.write/fs.rename/fs.mkdir/fs.remove/fs.remove_all/fs.list/fs.exists/fs.stat/fs.lstat/fs.find/fs.grep)
and the rationale.
#--allow-net[=hosts]
Grant the script the net capability — the ability to
make HTTP requests via the http module. Without it,
import http is a compile error (default-deny). With it, the
http module is available and the CLI backs it with a
built-in client (Zig's std.http and its pure-Zig TLS, so
HTTPS works with no system dependency).
A bare --allow-net allows any host.
--allow-net=host1,host2 restricts the script to a
comma-separated allowlist — a request to any other host returns an
Error before a packet is sent.
import http
match http.get("https://example.com")
Ok(resp) -> print(resp.status)
Error(why) -> print(why)
end
$ scua fetch.scua
fetch.scua:1: module `http` needs the `net` capability, which this run was not granted — grant it with `--allow-net` on the CLI, or the host's capability API when embedding
$ scua --allow-net=example.com fetch.scua
200
See Fetch data over HTTP
for http.get/http.post, the response shape,
and the allowlist.
--allow-serve[=ADDR:PORT]
Grant the script the serve capability — the ability to
listen and answer requests, via http.serve and the
raw net.listen/net.accept. This is a
different authority from --allow-net:
making outbound requests never lets a script open a port, so listening
is its own grant. Without it, http.serve and
net.listen don't exist to call (default-deny), and
--allow-net, however broad, never confers it.
The grant names the address the script may bind:
--allow-serve=127.0.0.1:8080 allows binding exactly that
address and port, and nothing else. A bare --allow-serve
allows any bind — reserve it for a host you trust.
$ scua --allow-serve=127.0.0.1:8080 serve.scua
See Serve HTTP requests for the handler shape, the request/response records, and raw TCP.
#--serve-debug
Put the fault text into an http.serve 500 body. By
default, when a handler faults or returns an Error, the
client gets a bare 500 and the located message is
withheld — production bodies don't leak an internal
detail to whoever is calling. --serve-debug puts that
message in the response body so you can see it while developing locally.
Leave it off in anything real.
$ scua --allow-serve=127.0.0.1:8080 --serve-debug serve.scua
#--allow-env[=NAMES]
Grant the script the env capability — the ability to
read environment variables via the env module. Without it,
import env is a compile error (default-deny). With it, the
env module is available and env.get(name)
reads from a snapshot of the environment taken when the run started.
A per-name allowlist is the safe default:
--allow-env=NAME1,NAME2 exposes only those variables, and
env.get returns nil for anything else, so a
script can't read a secret you didn't name. A bare
--allow-env exposes every variable.
import env
let home = env.get("HOME") ?? "(not set or not allowed)"
print(home)
$ scua whoami.scua
whoami.scua:1: module `env` needs the `env` capability, which this run was not granted — grant it with `--allow-env` on the CLI, or the host's capability API when embedding
$ scua --allow-env=HOME whoami.scua
/Users/sam
See Read
environment variables for env.get, the snapshot
semantics, and the allowlist.
#--env-file=PATH
Load a .env file and grant env for exactly
the names in it:
$ scua --env-file=.env app.scua
Naming the file is the grant — no separate
--allow-env, and no writing the file's key list twice. With
--env-file alone the script sees the file's names and
nothing else: the real process environment stays hidden, so a stray
secret in your shell can't reach it. Add
--allow-env=NAME,... for specific real variables too.
Precedence is one rule — nothing overwrites what is already set — applied in this order:
- the granted slice of the real process environment
- the first
--env-file - each later
--env-file
So a real variable beats the file (which is what makes
.env a defaults file), and an earlier file beats a
later one:
$ DB_HOST=prod.internal scua --env-file=.env app.scua # the real DB_HOST wins
$ scua --env-file=.env.local --env-file=.env app.scua # .env.local wins; .env fills the gaps
The format is the usual one: KEY=value,
# comments, an optional export prefix,
'literal' and "basic" quoting (the latter
processes \n, \r, \t,
\\, \"), either able to span lines.
KEY= is the empty string — set, not absent — and a repeated
key keeps its last value (while across several
--env-files, the first file wins).
Two things worth knowing: a Windows path needs single
quotes ("C:\temp" contains \t, so it
reads as a tab; 'C:\temp' is verbatim), and
${VAR} is not interpolated — it stays
literal, because a value that could name another variable would be a way
around the allowlist you just set.
A file the parser can't read stops the run and says why. See Read environment
variables, and dotenv.parse in the builtins reference for reading a
.env file as ordinary data with no grant.
--allow-cluster[=SECRET]
Grant the script the cluster capability — a small
cluster-wide config table that converges across nodes, via the
cluster module. Without it, import cluster is
a compile error (default-deny). SECRET is the cluster's
shared MAC secret: every node meshing the same table must present the
same one, and a mismatch is refused rather than trusted. A bare
--allow-cluster uses the empty secret, which is fine for
local dev and demos but not for anything real.
$ scua --allow-cluster=hunter2 config.scua
$ scua --allow-cluster config.scua # empty secret (local/demo)
See Share config across a cluster for the table surface and the multi-node story.
#--allow-udp[=PEERS]
Grant the script the udp capability — sending and
receiving UDP datagrams via the udp module. Without it,
import udp is a compile error (default-deny). This is a
separate grant from --allow-net, never
implied by it: a datagram can be aimed at a third party, so unscoped UDP
is its own authority.
The grant names peers, not just "yes".
--allow-udp=1.1.1.1:53,10.0.0.0/8:7946 is a comma-separated
allowlist of ADDR[:PORT] or CIDR[:PORT]. A
host is resolved and then the resulting address is checked, so a name
that resolves outside the allowlist is refused. A bare
--allow-udp allows any peer — keep it to a host you
trust.
Receiving from anyone (udp.bind) additionally needs
--allow-serve, since hearing from strangers is listen
authority on top of datagram authority.
$ scua --allow-udp=1.1.1.1:53 dns_query.scua
See Send datagrams with UDP for the socket surface and the guardrails.
#--udp-max-payload=N
Raise the datagram payload cap above its 1200-byte default. That
default is the portable-safe floor: a fragmented datagram is
all-or-nothing, so fragmenting multiplies your loss. Raise it only when
you know the path can carry larger packets. A send over the cap is a
loud Error, never a silent truncation.
#--allow-tty
Grant the script the tty capability — asking the person
at the keyboard a question, via sys.readline() and
sys.readpassword(). Without it those two don't exist to
call (default-deny), because a terminal is where a human types
passwords, so reading one is authority like fs or
net rather than something every script gets for free. An
actor never inherits it.
$ scua --allow-tty greet.scua
This is a different thing from piped input:
sys.stdin() reads a payload piped into the script and needs
no grant, and neither flag nor grant turns one into the other. In a
capability file the key is tty = true (or
tty = false to refuse it outright, which holds even under
--allow-all).
See Ask the user a question for the prompt shapes, the end-of-input rule, and why not to mix the two ways of reading input.
#--allow-sandbox
Grant the sandbox capability: the program may compile
and run SCUA source strings in isolated,
capability-free partitions (import sandbox).
This grants the authority to create a sandbox. The sandboxed code itself receives nothing — not this capability, and not any other one the run holds, so a sandbox cannot reach the filesystem, the network, the environment or the clock, and cannot create sandboxes of its own. There is no flag that relaxes that.
Use it when your program runs code that came from somewhere else: a player's mod, a rule someone typed in, a tool a model generated. See Run code in a sandbox.
#--io=<async|blocking>
Choose how the script's capability I/O (fs and
http calls) runs. The default is
--io=blocking: each call blocks until it returns, which is
exactly right for a straight-line script. --io=async runs
those calls on an offload pool instead, so that when several are in
flight under wait_all/map_all
the waits genuinely overlap.
This is an optimization flag, not a requirement. The results are identical either way — same values, same order, same all-settled behaviour — only the wall-clock differs. Write the code once and add the flag when overlap pays off.
$ scua --io=async --allow-net=api.example.com fetch_all.scua
--locale=<tag|host>
and --tz=<offset|host>
Set the system locale and timezone the script reads via
sys.locale() and sys.timezone(). Both are
neutral by default — with no flag,
sys.locale() is "und" (undetermined) and
sys.timezone() is 0 (UTC) — so a run is
reproducible across machines unless you say otherwise.
--locale=de-DEsets the locale tag (any BCP-47 string; stored verbatim).--tz=+02:00sets the UTC offset; accepts±HH:MM,±HH, andUTC/Z. Non-hour offsets work:--tz=+05:45. IANA zone names (Europe/Berlin) are not supported — use an offset.--locale=host/--tz=hostread the real device setting (fromLC_ALL/LC_CTYPE/LANGandTZ). This is the one explicit, opt-in source of nondeterminism — like--fastfor the clock.--tz=hostneeds a numericTZoffset; if the host only has a zone name, pass--tz=±HH:MMinstead.
$ scua app.scua # locale=und, tz=0 (UTC)
$ scua --locale=de-DE --tz=+02:00 app.scua # pin both (great for tests)
$ scua --locale=host --tz=host app.scua # read the device
The timezone offset feeds the date layer: time.now() and
time.of(...) default to it. See Set the locale and
timezone and Dates and
times.
#--mem-stats
and --gc-threshold=BYTES
Two diagnostics. They exist to answer "where is my
memory going", not to tune a deployment, and neither is a stability
promise — treat them the way you would --jit-stats.
--mem-stats prints one line to stderr as the script
exits:
$ scua --mem-stats build.scua
[mem] live=25291648 peak-heap=25291648 live-after-gc=8514416 peak-live-after-gc=8514416 \
final-live=16903024 peak-gc-transient=17087088 external=0 alloc-total=25349896 \
gc=1 gc-copied=8514416 demotions=0 partitions=1 all-live=25291648
| field | meaning |
|---|---|
live |
arena bytes plus external buffer bytes held right now — including garbage that hasn't been collected yet |
peak-heap |
the run's high-water for that figure; again, occupancy, not live data |
live-after-gc |
what was actually live immediately after the most recent collection
(0 if none ran) |
peak-live-after-gc |
the largest such live set over the run |
final-live |
the live set after one collection forced once your script has finished — the honest "what did this program end up holding" |
peak-gc-transient |
the most memory a single collection needed for both spaces at once |
external |
bytes in large element-typed buffers, which live outside the arena |
alloc-total |
every byte ever allocated, garbage included — the churn, which
live can't show |
gc / gc-copied |
collections, and the live bytes they copied |
demotions |
typed arrays that fell back to boxed storage |
partitions / all-live |
how many partitions exist, and their summed current occupancy |
The distinction that matters most: live and
peak-heap include garbage. SCUA allocates by
bumping a pointer, so until a collection runs it genuinely cannot tell
live data from dead. If you want "how big is my data", read
final-live. If you want "how much did the process have to
hold", read peak-heap. Every field except
partitions/all-live describes the main
partition only — each actor has its own.
--gc-threshold=BYTES sets how much a program may
allocate on top of what it is already holding before the
collector runs (default 8 MiB). Lower it and a script collects sooner
and holds less; raise it and it runs longer between collections and
holds more. The tradeoff is monotone in the direction you'd expect, so
it is a reasonable dial to turn as well as to look at:
$ scua --mem-stats --gc-threshold=256K build.scua
Note this is headroom above the live data, not a total. A program holding 200 MB with the default 8 MiB floor collects at about 208 MB, not at 8 MiB — otherwise a program whose data is simply large would be collected continuously and reclaim nothing each time.
#--serve-timeout=MS
How long one served HTTP request may run, in real milliseconds.
Default 30000 (30 seconds); 0 removes the
deadline. Only meaningful with --allow-serve.
Granting serve arms this — you do not have to ask for
it. A server is the one place where a script runs work chosen by someone
else, over and over, with nothing else bounding it, so it gets a bound
by default and you turn it off rather than on.
When a handler runs past the deadline, that request gets a 503 and the connection closes. The server itself keeps running and the next request is handled normally:
$ scua --allow-serve=127.0.0.1:8080 --serve-timeout=2000 --serve-debug slow.scua
HTTP/1.1 503
handler exceeded the request deadline (--serve-timeout)
The explanation in the body only appears with
--serve-debug; without it the 503 is bare, like every other
error a client sees.
Measured against the real clock, so it is not armed under
--fast or scua test, where the clock
fast-forwards and a deadline in virtual milliseconds would stop a
correct simulation immediately.
#--max-ops=N and
--max-mem=BYTES
Bound what an untrusted script may consume. Both take a count with an
optional k/m/g suffix
(--max-ops counts in 1000s, --max-mem in
1024-byte units), so --max-ops=5m is five million and
--max-mem=64M is 64 MiB. These are the CLI equivalents of
the op_budget and mem_cap arguments an
embedding host passes to scua_eval.
--max-ops caps the reduction steps —
loop iterations plus function calls — any single turn may run. When a
script exceeds it, an uncatchable kill-switch stops the run, naming the
budget it used and how to change it. A loop that never yields is bounded
in CPU, so it can't spin forever:
$ scua --max-ops=100k spin.scua
spin.scua:2: this turn used its whole CPU budget of 100000 steps, set by `--max-ops`. Raise it, or remove the budget with `--max-ops=none`
Without the flag there is no CPU limit. The rule is
one sentence: a budget you set kills; the built-in one does
not. There is still a built-in ceiling of 100,000,000 steps, but
reaching it now refills it and carries on, so a long computation simply
finishes — a loop of a few hundred million iterations used to be stopped
a third of the way through and no longer is. Type
--max-ops=N and you get a real kill-switch, exactly as
before.
That split is deliberate. Writing a tool at a command line, you are
not the person the limit was for; running someone else's script, you
are, and then you say so. The same rule holds everywhere a budget is
chosen for you: a sandbox mod's, and an embedding host's
op_budget, both still kill.
--max-ops=0 is refused rather than obeyed: a budget of
zero would stop the script on its first step, which nobody means when
they type it. For no limit, write none.
The budget is per turn, so it also caps each actor or coroutine turn the script spawns — and it is refilled at the top of each one. That means it bounds how much work any single turn may do, not how long a program may run: a loop that waits or polls yields between turns and is not bounded by this at all. Use a timeout on whatever the loop is waiting for.
The kill-switch is uncatchable on purpose:
try/rescue and pcall don't see
it, because a handler would resume with the budget still exhausted and
trip again immediately — a script could then loop forever catching its
own limit. The call-depth limit is different and
is catchable, because unwinding to the handler frees
the call frames that breached it; see Running out of
stack.
--max-mem caps the extra live memory
the script may hold beyond what it starts with. Over the cap, the run
stops with memory limit exceeded instead of growing without
bound:
$ scua --max-mem=64K hog.scua
hog.scua: memory limit exceeded
The cap is on the live set (it's checked after garbage is reclaimed), so ordinary churn that frees as it goes won't trip it — only genuinely holding more than the cap does.