SCUA

Manual

Command-line interface

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, 37 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 37 for them; keep your own choices in 02 and the two can't be confused. sys.exit accepts any value 0255, 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, and scua debug all work with scua-pkg absent. 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 of scua possible 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=on compiles hot loops as they warm up. This is what you reach for on a compute-heavy script.
  • --jit=off is the default: run everything on the interpreter.
  • --jit=always compiles 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:

  1. a --allow-* flag on this command line
  2. --allow-file=PATH
  3. --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:

  1. the granted slice of the real process environment
  2. the first --env-file
  3. 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-DE sets the locale tag (any BCP-47 string; stored verbatim).
  • --tz=+02:00 sets the UTC offset; accepts ±HH:MM, ±HH, and UTC/Z. Non-hour offsets work: --tz=+05:45. IANA zone names (Europe/Berlin) are not supported — use an offset.
  • --locale=host / --tz=host read the real device setting (from LC_ALL/LC_CTYPE/LANG and TZ). This is the one explicit, opt-in source of nondeterminism — like --fast for the clock. --tz=host needs a numeric TZ offset; if the host only has a zone name, pass --tz=±HH:MM instead.
$ 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.