The scua binary runs a script, 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 fmt [--check] <file.scua> (fixes indentation + trailing whitespace only)
scua test [dir] (run *_test.scua files)
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 debug <file.scua> (run under the terminal stepper)
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
--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-fs[=DIR] grant the `fs` capability, 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)
--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
--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)
--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); a runaway loop is killed
--max-mem=BYTES cap the script's extra live memory (suffix k/m/g, e.g. 64M)
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 1 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.
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.
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 test [dir]
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.
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.
#--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.
#--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
...
#--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-fs[=DIR]
Grant the script the fs capability — the ability to read
and write files — 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.
--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.list/fs.exists/fs.stat/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.
--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.
#--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.
#--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 with
execution budget exceeded. A runaway loop is bounded in
CPU, so it can't spin forever:
$ scua --max-ops=100k spin.scua
spin.scua:2: execution budget exceeded (possible infinite loop)
There is always a built-in ceiling even without the flag, but it's
deliberately high (so long legitimate scripts run); set
--max-ops to bound untrusted input tightly, or raise it for
a script you trust to run long. The budget is per turn, so it
also caps each actor or coroutine turn the script spawns.
--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.